MongoDB

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does), with Fluent 4.13.0, FluentKit 1.57.0, fluent-mongo-driver 1.4.0, MongoKitten 6.7.15 and BSON 7.0.31, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package. No MongoDB server was available while this page was written (no mongod on the machine and the Docker daemon was not running), so nothing here was run against a live database: the unit tests shown run without a server and check what can be checked that way (configuration errors, the cast to MongoDatabaseRepresentable, the BSON/JSON form of ObjectId, the HTTP status of a malformed id, and, through the driver’s own debug log, the exact MongoDB command a Fluent query is translated to; the listing shows the technique with one query and the other log lines on this page were captured the same way). Statements about behaviour on the wire that rest only on reading the driver’s source are marked as such. Vapor 5 is at 5.0.0-beta.4 and is not released yet; no Vapor-5-specific release of the driver exists. The page extends the Bookshelf service of the earlier pages with a document-shaped variant of its catalog. Swift itself is covered by the Swift Reference, and Sendable by Actors, Isolation and Sendable. The relational side of Fluent (SQLite, PostgreSQL and MySQL) is covered by Fluent Overview and Database Drivers, Fluent Models, Fluent Relations and Fluent Queries and Pagination; the same topic in Rust is in Databases with SQLx.

Read this first: the state of the Swift MongoDB drivers

Before choosing MongoDB for a Vapor service, check who maintains the code you depend on. Figures below come from the GitHub API on 2026-10-17.

Package Latest release Status

vapor/fluent-mongo-driver

1.4.0, 2024-10-06 ("Update For Swift 6")

Not archived. The only later commit on main is an API-docs workflow change on 2026-07-17. Two open pull requests date from 2022 and 2023 (sorting joined tables; using a given index name instead of "unique"), and an issue from 2024-12 reports "Reply received from MongoDB, but no request was waiting for the result" after about twelve hours.

orlandos-nl/MongoKitten

7.16.3, 2026-05-20 (7.x), but the 6.x line the Fluent driver uses ended at 6.7.15, 2024-07-24

Actively released on 7.x. The Fluent driver requires from: "6.7.13", which SwiftPM resolves to the 6.x line only, so a Vapor application that uses Fluent MongoDB stays on MongoKitten 6.7.15 and on BSON 7.0.31.

mongodb/mongo-swift-driver (MongoDB Inc.)

v1.3.1

Archived on GitHub; last push 2023-08. Its README announces that development has stopped and that "no further development, bug fixes, enhancements …​ or maintenance" will be provided, and points to MongoKitten as another option.

mongodb/mongodb-vapor (MongoDB Inc.)

v1.1.0

Archived on GitHub; last push 2023-07.

The practical reading is this. The driver works with Vapor 4.122 and Swift 6 (release 1.4.0 turned on complete strict concurrency checking in its own build), it has had no release for two years, and the pull request that would change how indexes are named has waited since 2023. Of the two options the Vapor documentation names in its MongoDB note, the official MongoDB driver and its Vapor integration, both are archived today, so the documentation’s pointer to them describes software that is no longer maintained. Choose MongoDB for a new Vapor service only if the data is naturally documents and you accept a small, quiet dependency that you may need to fork or work around; keep MongoKitten (Fluent’s engine) or a raw-driver service behind your own repository type so that replacing it does not touch handlers. If the data is relational (books, authors, loans, reviews with constraints and transactions), the SQL drivers of the Fluent pages are better maintained and better matched to Fluent’s model. Nothing on this page changes with this status; it only tells you how much to rely on it.

Where it sits

The driver is a Fluent database driver, so a MongoDB-backed model is reached exactly like a SQLite or PostgreSQL one: a handler calls Book.query(on: req.db), FluentKit builds a DatabaseQuery, and the driver turns that into a MongoDB command. What differs is everything below Fluent.

What a Fluent model and query become in MongoDB: Swift model declarations on the left, the FluentMongoDriver translation rules in the middle and the resulting document and commands on the right

In the request lifecycle (middleware, router, handler, response encoding) the database is used inside the handler:

  • app.databases.use(.mongo(...), as: .mongo) runs once at start-up and creates a MongoCluster with MongoCluster(lazyConnectingTo:on:). "Lazily" means no socket is opened yet; a wrong host or wrong credentials show up as an error from the first query, not from configure.

  • In a handler, req.db is a FluentMongoDatabase value bound to the request’s event loop (the test below checks this type through its raw property). Each Fluent operation creates one MongoDB command and sends it over a connection the cluster picks.

  • The driver is built on futures (EventLoopFuture) internally; Fluent’s async API sits on top. No handler code on this page uses a future.

Adding the dependency

// Package.swift (excerpt)
dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
    .package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
    .package(url: "https://github.com/vapor/fluent-mongo-driver.git", from: "1.4.0"),
],
targets: [
    .executableTarget(name: "App", dependencies: [
        .product(name: "Vapor", package: "vapor"),
        .product(name: "Fluent", package: "fluent"),
        .product(name: "FluentMongoDriver", package: "fluent-mongo-driver"),
    ]),
]

FluentMongoDriver re-exports ObjectId, Document, Primitive (from BSON) and GridFSFile (from MongoKitten), so import FluentMongoDriver is enough for the Fluent-level code on this page. To call MongoKitten itself (aggregations, indexes, collections) add import MongoKitten; it is a transitive dependency and needs no extra product entry.

Swift 6 trap: ObjectId and Document are not Sendable. BSON 7.0.31 does not mark them Sendable, so a model that stores an ObjectId fails to compile in the Swift 6 language mode:

error: type 'ObjectId' does not conform to the 'Sendable' protocol
error: type 'Document' does not conform to the 'Sendable' protocol
error: stored property 'userID' of 'Sendable'-conforming struct 'ReviewSnapshot' has non-Sendable type 'ObjectId'
warning: add '@preconcurrency' to treat 'Sendable'-related errors from module 'BSON' as warnings

Importing the driver with @preconcurrency import FluentMongoDriver (and @preconcurrency import MongoKitten where you use it) turns those errors into nothing: with the attribute the module compiled with zero warnings. The attribute goes on every file that mentions the types. The alternative, declaring extension ObjectId: @retroactive @unchecked Sendable {} yourself, changes the type for the whole process and breaks the day BSON adds its own conformance, so prefer @preconcurrency.

Connecting

The connection string

.mongo(connectionString:) takes a standard MongoDB URI and throws if it cannot be used:

import Fluent
@preconcurrency import FluentMongoDriver
import Vapor

func configure(_ app: Application) async throws {
    // MONGODB_URL looks like mongodb://USER:PASSWORD@HOST:27017/bookshelf?authSource=admin (placeholders).
    // The path must name the database: .mongo(connectionString:) throws without one.
    let url = Environment.get("MONGODB_URL") ?? "mongodb://127.0.0.1:27017/bookshelf?maxConnections=8"
    try app.databases.use(.mongo(connectionString: url), as: .mongo)

    app.migrations.add(CreateBookshelfCollections())
    try routes(app)
    if app.environment != .testing { try await app.autoMigrate() }
}

The parser (ConnectionSettings.init(_:) in MongoKitten 6.7.15) and the factory behave as follows; the errors were produced by the unit tests below:

  • The scheme must be mongodb:// or mongodb+srv://; anything else throws MongoInvalidUriError ("The given MongoDB connection URI is invalid"). mongodb+srv:// takes exactly one host, switches TLS on and resolves the cluster through DNS (the driver depends on DNSClient for this).

  • The URI must name the database (mongodb://host:27017/bookshelf). Without a path the factory throws FluentMongoError.noTargetDatabaseSpecified; the database name is the one Fluent uses for every collection.

  • Credentials are split on @ and :, so a username or password containing @, :, / or % must be percent-encoded (mongodb://u:p@w@h/db is rejected as malformed). The default authentication is automatic SCRAM; authMechanism accepts SCRAM-SHA-1, SCRAM-SHA-256 and MONGODB-CR, and authSource names the authentication database.

  • Query options the parser understands: ssl or tls, tlsCAFile, sslVerify, authSource, authMechanism, appname, maxConnections, connectTimeoutMS, socketTimeoutMS and dnsServer. Others, such as replicaSet and retryWrites, are kept in queryParameters but nothing in the Fluent driver acts on them.

  • Defaults when an option is absent: one connection per cluster (maximumNumberOfConnections is 1), a connect timeout of 300 seconds and a socket timeout of 300 seconds. A service that serves concurrent requests should set maxConnections explicitly (the example uses 8); 300 seconds of connect timeout means a dead host makes every request wait very long unless you lower connectTimeoutMS.

  • .mongo(settings:) takes a ConnectionSettings value, for configuration built in code (hosts, useSSL, timeouts, Authentication.scramSha256(username:password:)) instead of a string. It throws FluentMongoError.missingHosts when the host list is empty.

  • There is no app.databases.use(..., maxConnectionsPerEventLoop:) as on the SQL drivers; pool size is the one setting above.

The URL belongs in the environment, never in the source (see Configuration and Environment). Treat anything with credentials as a secret: the example above reads MONGODB_URL and falls back to an unauthenticated local server for development only. A production URL looks like mongodb+srv://bookshelf:<password>@cluster.example.net/bookshelf?authSource=admin with a real password in a secret store. Never run production without authentication and TLS; the older Vapor 2 book setup of a MongoDB server with access control off is a development shortcut at best.

Several databases

DatabaseID.mongo is just the identifier the driver defines (DatabaseID(string: "mongo")). To use two MongoDB databases, or MongoDB next to SQLite, register each under its own DatabaseID and pick it per model or per query:

import Fluent
@preconcurrency import FluentMongoDriver
import Vapor

extension DatabaseID {
    static let analytics = DatabaseID(string: "analytics")
}

/// A model stored in the second database.
final class PageView: Model, @unchecked Sendable {
    static let schema = "page_views"
    static let defaultDatabaseID: DatabaseID? = .analytics

    @ID(custom: .id) var id: ObjectId?
    @Field(key: "path") var path: String

    init() {}
    init(id: ObjectId? = nil, path: String) {
        self.id = id
        self.path = path
    }
}

func configureTwoDatabases(_ app: Application, catalogURL: String, analyticsURL: String) throws {
    try app.databases.use(.mongo(connectionString: catalogURL), as: .mongo, isDefault: true)
    try app.databases.use(.mongo(connectionString: analyticsURL), as: .analytics)
}

func viewCount(_ req: Request) async throws -> Int {
    try await PageView.query(on: req.db(.analytics)).count()
}

/// Deleting more than one document: `limit` must be absent or 1, so delete by id set instead.
func deleteBooks(withIDs ids: [ObjectId], on database: any Database) async throws {
    try await Book.query(on: database).filter(\.$id ~~ ids).delete()
}

A development server in Docker

# compose.yaml - development only; not run while writing this page
services:
  mongo:
    image: mongo:8
    command: ["--replSet", "rs0", "--bind_ip_all"]
    ports:
      - "27017:27017"
    healthcheck:
      test: >
        mongosh --quiet --eval
        "try { rs.status().ok } catch (e) { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]}).ok }"
      interval: 5s
      retries: 20

A single-node replica set is what makes db.transaction available (see below); a standalone mongod rejects transactions. The file was not run (the Docker daemon was off), and which MongoDB server versions the 2024 driver and its MongoKitten 6.7 protocol layer work with was not verified either; the Vapor documentation says the driver supports server versions "3.4 and up".

Models with ObjectId

MongoDB’s native identifier is the 12-byte ObjectId, stored in the _id field. The Vapor documentation’s rule is: use @ID(custom: .id) with ObjectId for a MongoDB-only model, and keep the default @ID(key: .id) with UUID if the same model must also run on a SQL driver. The driver maps the field key .id to _id, and it makes ObjectId generate itself (ObjectId is declared RandomGeneratable), so create assigns the id on the client.

import Fluent
@preconcurrency import FluentMongoDriver
import Vapor

enum BookStatus: String, Codable, Sendable { case draft, published, archived }

/// A review embedded in its book document instead of living in its own collection.
struct ReviewSnapshot: Content {
    var userID: ObjectId
    var rating: Int
    var body: String
}

final class Author: Model, @unchecked Sendable {
    static let schema = "authors"

    @ID(custom: .id) var id: ObjectId?
    @Field(key: "name") var name: String
    @OptionalField(key: "bio") var bio: String?
    @Children(for: \.$author) var books: [Book]

    init() {}
    init(id: ObjectId? = nil, name: String, bio: String? = nil) {
        self.id = id
        self.name = name
        self.bio = bio
    }
}

final class Book: Model, @unchecked Sendable {
    static let schema = "books"

    @ID(custom: .id) var id: ObjectId?
    @Field(key: "title") var title: String
    @Field(key: "isbn") var isbn: String
    @Field(key: "published_year") var publishedYear: Int
    @Field(key: "price_cents") var priceCents: Int
    @Enum(key: "status") var status: BookStatus
    @Parent(key: "author_id") var author: Author
    @Timestamp(key: "created_at", on: .create) var createdAt: Date?
    @Timestamp(key: "updated_at", on: .update) var updatedAt: Date?

    // Page-local additions: nested data instead of the tags/reviews tables of the SQL model set.
    @Field(key: "tags") var tags: [String]
    @Field(key: "reviews") var reviews: [ReviewSnapshot]
    @OptionalField(key: "metadata") var metadata: Document?

    init() {}
    init(
        id: ObjectId? = nil, title: String, isbn: String, publishedYear: Int, priceCents: Int,
        status: BookStatus = .draft, authorID: ObjectId, tags: [String] = []
    ) {
        self.id = id
        self.title = title
        self.isbn = isbn
        self.publishedYear = publishedYear
        self.priceCents = priceCents
        self.status = status
        self.$author.id = authorID
        self.tags = tags
        self.reviews = []
        self.metadata = nil
    }
}

The model follows the Bookshelf names (authors, books, isbn, price_cents, author_id, status) with these page-local additions that exist only because this is a document database: tags (an array of strings, replacing the tags table and the book_tag pivot of the SQL model set), reviews (an array of embedded ReviewSnapshot values, replacing the reviews table) and metadata (a free-form BSON Document). Users and loans are left out; they would be separate collections referenced by ObjectId, as author_id is.

How the property wrappers map:

  • @Field/@OptionalField store the value under its key in the document. Codable values are converted with BSONEncoder: Date becomes a BSON date, [String] an array, ReviewSnapshot a nested document, Document itself unchanged. A missing key reads back as nil for optional fields and as a decoding error for required ones, so adding a required field to a model makes old documents unreadable until they are migrated.

  • @Enum stores the enum’s raw string; db.enum(...) in a migration does nothing on MongoDB (the driver’s execute(enum:) succeeds without touching the server), so there is no server-side enum check.

  • @Timestamp works as on SQL: Fluent sets created_at/updated_at before the command is built (the logged update below shows updated_at inside the $set).

  • @Parent stores the parent’s ObjectId under its key and @Children reads it back with a filter on that key. These are references, resolved with extra queries (with(\.$author)) or $lookup, never database-enforced: nothing stops a author_id pointing at a deleted author.

  • @Siblings with a pivot model also works mechanically, but a pivot collection is the relational idiom this database is designed to avoid; store the array of related ids or names instead, as tags does here.

The id is hexadecimal text outside BSON. The unit test below confirms that ObjectId encodes as a 24-character hex string with JSONEncoder (so a Content struct containing one produces ordinary JSON), that ObjectId(String) parses it back, and that req.parameters.get("bookID", as: ObjectId.self) works because ObjectId is LosslessStringConvertible.

A model that is both a Fluent model and an API response is a trade-off exactly as on the SQL pages: the ObjectId id is Codable, but returning the model leaks every stored field (including internal ones). BookDTO in the routes below is the alternative: a Content struct with id as the hex string.

Document modelling in Bookshelf

Data modelling guidance from MongoDB itself applies: embed what is read together and changes together, reference what grows without bound or is shared. In Bookshelf:

  • tags and metadata are embedded because they are read with the book and have no life of their own.

  • reviews is embedded here for reading speed with a small bound (a few dozen); a popular title with thousands of reviews would make the book document grow without limit (MongoDB documents are capped at 16 MiB) and every review write rewrites a large document, so it would go into its own collection with book_id, like author_id.

  • author_id is a reference: authors are shared by many books and edited on their own.

Embedding changes how updates behave. book.reviews.append(review); try await book.update(on: db) rewrites the whole array, so two concurrent requests that both load the book and append lose one review. Fluent offers no $push; do the atomic update through MongoKitten (see "Raw access") or make reviews their own collection.

Migrations and indexes

The general migration mechanism is described in Migrations & Schema.

MongoDB creates collections implicitly on first insert, so the schema builder of a Fluent migration does much less than on SQL (this is from the driver’s MongoDB+Schema.swift):

  • create and update look only at unique constraints: for each .unique(on:) or composite identifier they send a createIndexes command for those keys. Fields, data types, .required, enums and foreign keys are ignored.

  • delete drops the collection.

  • There is no way to create a plain (non-unique), text, TTL or geospatial index through the schema builder.

import Fluent
@preconcurrency import FluentMongoDriver
import Vapor

struct CreateBookshelfCollections: AsyncMigration {
    func prepare(on database: any Database) async throws {
        // On MongoDB a schema builder only creates indexes for unique constraints; fields are ignored.
        try await database.schema(Author.schema).create()
        try await database.schema(Book.schema).unique(on: "isbn").create()
    }

    func revert(on database: any Database) async throws {
        try await database.schema(Book.schema).delete()
        try await database.schema(Author.schema).delete()
    }
}

/// Indexes the schema builder cannot express: plain (non-unique), text and TTL indexes need MongoKitten.
struct AddBookSearchIndexes: AsyncMigration {
    func prepare(on database: any Database) async throws {
        guard let mongo = database as? any MongoDatabaseRepresentable else { throw FluentMongoError.notMongoDB }
        let books = mongo.raw[Book.schema]
        // Legacy bridge: MongoKitten 6.7.15's createIndex returns EventLoopFuture and has no async overload.
        try await books.createIndex(named: "published_year_1", keys: ["published_year": 1]).get()
        try await books.createIndex(named: "title_text", keys: ["title": "text"]).get()
    }

    func revert(on database: any Database) async throws {
        // MongoKitten 6.7.15 has no dropIndex helper; run db.books.dropIndex("title_text") in mongosh,
        // or drop the whole collection in the migration that created it.
    }
}

func createBook(_ book: Book, on database: any Database) async throws {
    do {
        try await book.create(on: database)
    } catch FluentMongoError.insertFailed {
        // A duplicate isbn reaches Fluent as insertFailed: the driver does not surface the 11000 code.
        throw Abort(.conflict, reason: "A book with this ISBN may already exist")
    }
}

CreateBookshelfCollections is the first migration and AddBookSearchIndexes the second. Register both with app.migrations.add(_:) (configure above adds the first; add the second the same way). Run them as on any Fluent project (swift run App migrate, or the autoMigrate call in configure), and Fluent records them in the _fluent_migrations collection.

Things to know, from the source:

  • Every unique index the driver creates is named "unique". Two unique indexes on one collection with different keys conflict by name on a real server (not run here). An open pull request (#60, since 2023-06) would let the migration choose the name. Until it lands, one .unique(on:) per collection, or create further indexes with createIndex(named:keys:) as AddBookSearchIndexes does.

  • MongoKitten 6.7.15 has helper functions SortedIndex, UniqueIndex, TTLIndex and TextScoreIndex for the MongoIndexBuilder, but SortedIndex calls .unique() in its implementation, so it creates a unique index. Use createIndex(named:keys:) or CreateIndexes.Index directly if you want the index you name.

  • There is no dropIndex helper in that MongoKitten version; reverting an index means mongosh or sending the raw dropIndexes command.

  • Backfilling a new required field is a normal update (Book.query(on: db).set(...).update()), run in a migration, before the model starts requiring it.

Querying

Fluent’s query API is the same as on SQL, but the driver can translate only part of it. The driver logs the command it builds at debug level before it connects, so the translation can be read without a server. The test used for that:

@testable import App
import Fluent
@preconcurrency import FluentMongoDriver
import Testing
import Vapor
import VaporTesting

@Suite("Query translation without a server")
struct QueryTranslationTests {
    /// Runs `body` against an application whose MongoDB host does not exist.
    /// The driver connects lazily and logs the command it builds before it needs a connection,
    /// so the debug log shows the translation even though every query then fails.
    func withOfflineApp(_ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        app.logger.logLevel = .debug
        try app.databases.use(.mongo(connectionString: "mongodb://127.0.0.1:1/bookshelf?connectTimeoutMS=300"), as: .mongo)
        do {
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test func fluentDatabaseIsMongoDatabaseRepresentable() async throws {
        try await withOfflineApp { app in
            let mongo = try #require(app.db as? any MongoDatabaseRepresentable)
            #expect(mongo.raw.name == "bookshelf")
        }
    }

    @Test func containsBuildsAnUnescapedRegex() async throws {
        try await withOfflineApp { app in
            // Fails with a connection error, after logging: filter=["title": ["$regex": ".*a.b.*"]]
            _ = try? await Book.query(on: app.db).filter(\.$title ~~ "a.b").all()
        }
    }

    @Test func deleteWithLimitIsRejected() async throws {
        try await withOfflineApp { app in
            await #expect(throws: FluentMongoError.self) {
                try await Book.query(on: app.db).limit(5).delete()
            }
        }
    }

    @Test func missingDatabaseNameIsRejected() throws {
        #expect(throws: FluentMongoError.self) {
            _ = try DatabaseConfigurationFactory.mongo(connectionString: "mongodb://127.0.0.1:27017")
        }
    }

    @Test func malformedUriIsRejected() throws {
        #expect(throws: (any Error).self) {
            _ = try DatabaseConfigurationFactory.mongo(connectionString: "postgres://127.0.0.1/bookshelf")
        }
    }

    @Test func objectIdIsAHexStringInJSON() throws {
        let review = ReviewSnapshot(userID: ObjectId(), rating: 5, body: "Great")
        let json = String(decoding: try JSONEncoder().encode(review), as: UTF8.self)
        let back = try JSONDecoder().decode(ReviewSnapshot.self, from: Data(json.utf8))
        #expect(back.userID == review.userID)
        #expect(json.contains(review.userID.hexString))
    }
}

What the debug log shows for the queries tried (swift test output, abridged: the projection listing every field is cut):

filter(\.$title ~~ "a.b")
  find "books" filter: ["title": ["$regex": ".*a.b.*"]]

filter(\.$title =~ "Swift").filter(\.$isbn ~= "X")
  find "books" filter: ["$and": [["title": ["$regex": "Swift.*"]], ["isbn": ["$regex": ".*X"]]]]

group(.or) { status == .draft, publishedYear > 2020 }.sort(\.$title, .descending).range(10..<20)
  find "books" filter: ["$or": [["status": ["$eq": "draft"]], ["published_year": ["$gt": 2020]]]]
       sort: ["title": -1]  skip: 10  limit: 10

filter("tags", .equal, "swift").filter("metadata.publisher", .equal, "Packt")
  find "books" filter: ["$and": [["tags": ["$eq": "swift"]], ["metadata.publisher": ["$eq": "Packt"]]]]

filter(.custom(["title": ["$regex": "^swift", "$options": "i"]]))
  find "books" filter: ["title": ["$regex": "^swift", "$options": "i"]]

filter(\.$status == .published).count()
  count condition: ["status": ["$eq": "published"]]

sum(\.$priceCents)
  find-group operation=$sum field=price_cents condition=[]

batch: filter(\.$status == .draft).set(\.$priceCents, to: 0).update()
  update filter: ["status": ["$eq": "draft"]]  updates: [["price_cents": 0, "updated_at": 2026-10-17 15:29:31 +0000]]

What each construct does:

Fluent MongoDB translation and caveats

==, !=, >, >=, < and <= against a value

$eq, $ne, $gt, $gte, $lt, $lte. ~~ with an array on the right (Fluent’s "in") becomes $in, and !~ $nin; the log for filter(\.$status ~~ [.draft, .archived]) is ["status": ["$in": ["draft", "archived"]]].

~~ (contains), =~ (prefix), ~= (suffix) on a string

$regex with .*value.*, value.*, .*value. The value is not escaped: "a.b" matches "axb", C++ is read as a possessive quantifier and matches any title with a C in it, and an unbalanced ( is an invalid regular expression. The match is case-sensitive and has no $options. Patterns are not anchored with ^, so prefix search cannot use an index efficiently.

group(.or), nested group

$or and $and of the inner documents; several top-level filter calls are combined with $and.

filter("tags", .equal, x)

A field name given as a string reaches the server as written. On an array field Mongo’s equality means "contains the element".

filter("metadata.publisher", .equal, x)

Dot notation into an embedded document works because the string key is passed through (FieldKey.string becomes the path).

filter(.custom(document))

Any filter Document you build is used as is (anything else under .custom throws unsupportedCustomFilter). This is the place for $options, $elemMatch, $exists and the other operators the Fluent API lacks.

sort, range, limit, offset

sort, skip and limit of the find command. .custom limits throw unsupportedCustomLimit.

count()

count command with the filter.

sum, average, min, max

Computed by the driver with a find whose projection is {n: {$sum: "$field"}} and whose first result is read (the log line find-group operation=$sum). A projection expression is evaluated per document, not as a grouping, so from the driver source this returns one document’s value rather than a total. It was not run against a server; treat these four as unreliable and use an aggregation pipeline for totals (below).

join, join(..., method: .left)

An aggregation: $replaceRoot wrapping the document under its collection name, $lookup against the other collection, $unwind for inner joins, $match, $skip, $limit, $project. Only a single equality between a field and the other collection’s field is supported; other join filters throw unsupportedJoin. Sorts are silently ignored in a joined query, because no sort stage is built (open pull request #53 asks for it).

with(\.$author)

Not a join: Fluent first runs the base find, then a second find with _id in the collected ids ($in), and attaches the results. Two round trips regardless of the number of rows.

paginate(PageRequest(page:per:))

A count command, then a find with skip and limit - skip-based paging, which gets slower on deep pages.

update() after set(...)

An update command with $set of the given fields and multi: true. Fluent adds updated_at for a model with that timestamp.

delete() on a query

A delete command: all matches, or one when .limit(1) is given. Any other limit throws unsupportedCustomLimit (the test deleteWithLimitIsRejected checks this).

.sql(unsafeRaw:), filter(.sql(...))

Not applicable: guard SQL-specific code with if req.db is SQLDatabase as the Vapor documentation does.

A search route that uses all of the filter styles above: a case-insensitive title match built as a custom Document with the user’s text escaped, an array-contains filter and a dot-notation filter. The aggregation route is the way to compute grouped figures, and the review route shows the embedded-array update.

import Fluent
@preconcurrency import FluentMongoDriver
@preconcurrency import MongoKitten
import Vapor

struct BookDTO: Content {
    var id: String?
    var title: String
    var isbn: String
    var tags: [String]
    var reviewCount: Int
}

extension Book {
    func toDTO() -> BookDTO {
        BookDTO(id: id?.hexString, title: title, isbn: isbn, tags: tags, reviewCount: reviews.count)
    }
}

struct BookSearch: Content {
    var q: String?
    var tag: String?
    var publisher: String?
}

struct AuthorStats: Content {
    var authorID: ObjectId
    var books: Int
    var avgPriceCents: Double
}

struct BookController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.get("search", use: search)
        books.get("stats", use: stats)
        books.post(":bookID", "reviews", use: addReview)
    }

    @Sendable func search(req: Request) async throws -> [BookDTO] {
        let criteria = try req.query.decode(BookSearch.self)
        let query = Book.query(on: req.db).sort(\.$title)
        if let text = criteria.q {
            // Case-insensitive title match; the text is escaped, so "C++" matches literally.
            var title = Document()
            title["title"]["$regex"] = NSRegularExpression.escapedPattern(for: text)
            title["title"]["$options"] = "i"
            query.filter(.custom(title))
        }
        if let tag = criteria.tag {
            query.filter("tags", .equal, tag) // matches when the array contains the element
        }
        if let publisher = criteria.publisher {
            query.filter("metadata.publisher", .equal, publisher) // dot-notation into a nested document
        }
        return try await query.all().map { $0.toDTO() }
    }

    @Sendable func stats(req: Request) async throws -> [AuthorStats] {
        guard let mongo = req.db as? any MongoDatabaseRepresentable else {
            throw Abort(.internalServerError, reason: "Not a MongoDB database")
        }
        var group = Document()
        group["_id"] = "$author_id"
        group["books"] = ["$sum": 1] as Document
        group["avgPriceCents"] = ["$avg": "$price_cents"] as Document
        let pipeline = mongo.raw[Book.schema].aggregate([
            AggregateBuilderStage(document: ["$group": group]),
            AggregateBuilderStage(document: ["$sort": ["avgPriceCents": -1] as Document]),
        ])
        let documents = try await pipeline.decode(Raw.self).allResults()
        return documents.map { AuthorStats(authorID: $0._id, books: $0.books, avgPriceCents: $0.avgPriceCents) }
    }

    private struct Raw: Decodable {
        var _id: ObjectId
        var books: Int
        var avgPriceCents: Double
    }

    @Sendable func addReview(req: Request) async throws -> HTTPStatus {
        guard let id = req.parameters.get("bookID", as: ObjectId.self) else { throw Abort(.badRequest) }
        let review = try req.content.decode(ReviewSnapshot.self)
        guard let book = try await Book.find(id, on: req.db) else { throw Abort(.notFound) }
        book.reviews.append(review)
        try await book.update(on: req.db)
        return .created
    }
}

func routes(_ app: Application) throws {
    try app.register(collection: BookController())
}

The escaping matters. With the request GET /books/search?q=C%2B%2B&tag=swift&publisher=Packt, the driver’s debug log (captured from an offline run, so the response was a 500; the log lines are the point) shows the command with the escaped pattern:

find "books" filter: ["$and": [["title": ["$regex": "C\\+\\+", "$options": "i"]],
                              ["tags": ["$eq": "swift"]],
                              ["metadata.publisher": ["$eq": "Packt"]]]]  sort: ["title": 1]

and the same request built with filter(\.$title ~~ text) would have sent .*C++.*, in which C++ is a possessive quantifier, so it would have matched titles that merely contain a C. A user-supplied pattern is also a denial-of-service vector (a pathological expression runs on the server), so never pass raw user text as a regex.

# not run against a live server
curl -s 'http://localhost:8080/books/search?q=swift&tag=vapor'
curl -s 'http://localhost:8080/books/stats'
curl -s -X POST http://localhost:8080/books/65f0a1b2c3d4e5f607182930/reviews \
  -H 'content-type: application/json' \
  -d '{"userID":"65f0a1b2c3d4e5f607182931","rating":5,"body":"Clear and practical"}'

The only response observed, from the test with no database, is the one for a malformed id (the id is checked before any query):

POST /books/not-an-object-id/reviews  ->  400 {"error":true,"reason":"Bad Request"}

The test that asserts it:

@testable import App
import Testing
import Vapor
import VaporTesting

@Suite("Routes that do not need MongoDB")
struct RouteStatusTests {
    @Test func malformedObjectIdIsABadRequest() async throws {
        let app = try await Application.make(.testing)
        try await configure(app)
        try await app.testing().test(.POST, "books/not-an-object-id/reviews", beforeRequest: { req in
            try req.content.encode(["rating": 5])
        }, afterResponse: { res async in
            #expect(res.status == .badRequest)
        })
        try await app.asyncShutdown()
    }
}

Transactions

For the SQL side, including model middleware and soft delete, see Transactions, Model Middleware & Soft Delete.

req.db.transaction { ... } on this driver starts a MongoDB session transaction (MongoDatabase.startTransaction(autoCommitChanges: false)), runs the closure with a database bound to it, commits on success and aborts if the closure throws. Three constraints, all from the source:

  • MongoDB supports multi-document transactions only on a replica set or a sharded cluster; a standalone mongod refuses.

  • MongoKitten checks pool.wireVersion?.supportsReplicaTransactions before it starts one. Before the cluster has ever connected the wire version is unknown, and the call fails at once with MongoKittenError(kind: .unsupportedFeatureByServer). The offline test run produced exactly that error, so a transaction attempted as the very first operation after start-up may fail on a perfectly good replica set; run a cheap query (or a health check) first.

  • A nested transaction call reuses the outer transaction.

If a document already holds everything the operation changes (the embedded-reviews design), a single-document update is atomic without a transaction, which is the reason to model that way.

Raw access with MongoDatabaseRepresentable

Anything the Fluent API lacks (grouped aggregation, $push, text search, indexes, findOneAndUpdate) is available through the underlying MongoKitten MongoDatabase. Cast the Fluent database to MongoDatabaseRepresentable and read raw:

guard let mongo = req.db as? any MongoDatabaseRepresentable else {
    throw Abort(.internalServerError, reason: "Not a MongoDB database")
}
let books = mongo.raw[Book.schema]   // a MongoKitten MongoCollection

This excerpt appears in the stats route above, and the test fluentDatabaseIsMongoDatabaseRepresentable confirms the cast succeeds on the type req.db returns. The stats route runs a $group and $sort pipeline; its stages are written as Document values with explicit as Document conversions, because a nested dictionary literal of mixed value types does not type-check. The raw collection shares the Fluent cluster and its connection limit, so raw calls do not open another pool.

The driver also adds two conveniences on top of the raw handle, both EventLoopFuture-returning, so await them with .get():

  • GridFSFile.upload(_:named:metadata:on:), GridFSFile.find(_:on:) and GridFSFile.read(_:on:) store and fetch large files in a GridFS bucket in the Fluent database (GridFSBucket(in: raw)). They fail with FluentMongoError.notMongoDB on a non-Mongo database.

  • Model.watch(on:options:) opens a MongoDB change stream on the model’s collection (also a replica-set feature) and returns a MongoKitten ChangeStream.

Neither was run (they need a server); their signatures are those of GridFS.swift and ChangeStream.swift in the 1.4.0 tag.

MongoKitten without Fluent

When MongoDB is the only database and Fluent’s model layer adds nothing, MongoKitten can be used directly. The driver’s own dependency means MongoKitten is already in the build; the listing registers a MongoDatabase in app.storage, shuts its cluster down with a LifecycleHandler and exposes two routes.

import Vapor
@preconcurrency import MongoKitten

/// A catalog entry stored by MongoKitten without Fluent.
struct CatalogEntry: Codable, Sendable {
    var _id: ObjectId
    var title: String
    var tags: [String]
}

/// MongoKitten's `MongoDatabase` is a reference type that is not marked `Sendable`.
struct DirectMongo: @unchecked Sendable {
    let database: MongoDatabase
}

extension Application {
    private struct DirectMongoKey: StorageKey { typealias Value = DirectMongo }

    var directMongo: DirectMongo {
        get {
            guard let value = self.storage[DirectMongoKey.self] else {
                fatalError("Call configureDirectMongo(_:url:) first")
            }
            return value
        }
        set { self.storage[DirectMongoKey.self] = newValue }
    }
}

struct DirectMongoShutdown: LifecycleHandler {
    func shutdownAsync(_ application: Application) async {
        if let cluster = application.directMongo.database.pool as? MongoCluster {
            try? await cluster.disconnect().get()
        }
    }
}

func configureDirectMongo(_ app: Application, url: String) throws {
    let database = try MongoDatabase.lazyConnect(url, on: app.eventLoopGroup)
    app.directMongo = DirectMongo(database: database)
    app.lifecycle.use(DirectMongoShutdown())
}

func catalogRoutes(_ app: Application) {
    app.get("catalog", "search") { req async throws -> [String] in
        let tag = try req.query.get(String.self, at: "tag")
        let entries = req.application.directMongo.database["catalog"]
        let found = try await entries.find(["tags": tag] as Document).decode(CatalogEntry.self).allResults()
        return found.map(\.title)
    }
    app.post("catalog") { req async throws -> HTTPStatus in
        let title = try req.query.get(String.self, at: "title")
        let entries = req.application.directMongo.database["catalog"]
        _ = try await entries.async.insertEncoded(CatalogEntry(_id: ObjectId(), title: title, tags: ["new"]))
        return .created
    }
}

Notes on the listing:

  • MongoDatabase is a reference type that is not Sendable, so the stored value is a small wrapper marked @unchecked Sendable, which is what StorageKey requires. The wrapper only tells the compiler what the library’s documentation and design promise; it is not checked.

  • MongoDatabase.lazyConnect(_:on:) gets app.eventLoopGroup, so MongoKitten shares Vapor’s threads. This listing creates a second cluster next to any Fluent one; if you use both, take the raw handle from Fluent instead.

  • find(...).decode(Type.self).allResults() has an async overload; insertEncoded returns a future, so the code calls it through .async. There are no Task hops to write.

  • MongoKitten 7.x (newer than the 6.7.15 pinned by Fluent) is the line still being released, so a service that does not use the Fluent driver can depend on it directly. Its API differs from the 6.x calls above (check its documentation); the listing was compiled against 6.7.15 only.

Error handling

The driver reports problems as FluentMongoError, MongoKitten’s MongoError and MongoWriteError, or as a MongoKittenError. FluentMongoError, MongoError and MongoWriteError conform to Fluent’s DatabaseError, and every isConstraintFailure, isSyntaxError and isConnectionClosed is false, so code written for the SQL drivers that maps isConstraintFailure to 409 never fires. Cases seen in the offline run:

Situation Error Notes

URI without a database

FluentMongoError.noTargetDatabaseSpecified

Thrown by .mongo(connectionString:) at start-up.

Wrong scheme or malformed credentials

MongoInvalidUriError ("The given MongoDB connection URI is invalid")

Thrown by the same call. The connection string, including the password, can end up in logs of the code that catches it; do not print the string.

Host unreachable, wrong credentials

MongoError: "No hosts could be connected with, therefore no queries can be sent at the moment" followed by hints

Raised by the first query, after connectTimeoutMS (default 300 s). MongoKitten also logs "Connection to Host(…​) disconnected with error …​" at error level for each attempt.

delete() with a limit other than 1

FluentMongoError.unsupportedCustomLimit

Raised before any network call.

Unsupported operator, join or custom filter

unsupportedOperator, unsupportedJoin, unsupportedCustomFilter, unsupportedCustomSort, unsupportedField, unsupportedDefaultValue, …​

All cases of FluentMongoError.

Transaction on a server not ready for it

MongoKittenError kind unsupportedFeatureByServer

See "Transactions".

Duplicate value in a unique index

Source reading: the driver’s create requires the insert reply to report ok == 1 and an insert count equal to the number of documents sent, otherwise it throws FluentMongoError.insertFailed

A duplicate key returns ok: 1, n: 0 with a writeErrors entry, so a unique-index violation on create surfaces as insertFailed and the error code 11000 is lost. Not observed on a live server.

createBook in the migrations listing shows a conservative mapping:

  • catch FluentMongoError.insertFailed and answer 409;

  • say "may already exist" in the response (the error can in principle have other causes);

  • for certainty, query by the unique key first (race-prone) or insert through MongoKitten and read the InsertReply.writeErrors yourself.

A request that fails with a driver error is an unexpected failure: Vapor’s default ErrorMiddleware turns an error that is not an Abort into a 500 with the error’s reason, which in the testing environment was the text above including the "Check if your hosts are online" hints. In a release build the default middleware hides the details of non-Abort errors; do not rely on that to hide a connection string that may be echoed by other code. See the Error Handling page for the mapping of domain errors to Abort.

The open driver issue about "Reply received from MongoDB, but no request was waiting for the result" after about twelve hours of uptime (issue #62) describes a connection that stopped working after a long idle period. If you see it, restarting the process recovers; a health check that runs a cheap query regularly (and fails readiness when it errors) turns it into a restart.

Swift 6 and concurrency notes

  • The package’s own build uses StrictConcurrency=complete and @preconcurrency imports of MongoKitten, so it is a Swift 6-era package, but its public types from BSON (ObjectId, Document) and MongoKitten (MongoDatabase, MongoCollection) are not Sendable. The @preconcurrency import shown above is the supported way to use them from Swift 6 code; the compiler then does not check that you pass them across isolation boundaries safely. Keep Document and ObjectId values local to one task, and copy the fields you need into Sendable structs (AuthorStats, BookDTO) before returning or storing them.

  • Fluent models remain final class ...: Model, @unchecked Sendable as on the SQL pages. A model holding a Document field is mutable shared state; do not hand one instance to two tasks.

  • The FluentMongoDatabase runs commands on the cluster’s connection and hops the result back to the request’s event loop, so await`ing Fluent from a handler keeps the usual `async semantics. The @preconcurrency attributes on the driver’s own execute methods mean it is not isolation-checked by the compiler either.

  • app.databases.use(...) and the DirectMongoShutdown handler run on the application’s lifecycle. The cluster is shut down when the application shuts down (the Fluent driver calls cluster.disconnect() in shutdownAsync), so try await app.asyncShutdown() in the entrypoint is required.

Common errors and pitfalls

Symptom Cause and fix

type 'ObjectId' does not conform to the 'Sendable' protocol

BSON 7.0.31 types are not Sendable. @preconcurrency import FluentMongoDriver (and MongoKitten) in each file that uses them.

noTargetDatabaseSpecified

The URI has no /database path. Add it (mongodb://host:27017/bookshelf).

Every request hangs for minutes, then "No hosts could be connected with"

The server is unreachable and connectTimeoutMS defaults to 300 s. Lower it in the URI and add a start-up check query.

Throughput does not scale with concurrency

The default pool is one connection. Add maxConnections=N.

.mongo password with @ or : is rejected

Percent-encode credentials.

A search for ( returns 500, and C++ matches the wrong rows

~~, =~, ~= build unescaped regexes. Escape with NSRegularExpression.escapedPattern(for:) and use a .custom Document.

unsupportedCustomLimit on delete()

Only no limit (all matches) or limit(1) can be deleted; load the ids and delete them with filter(\.$id ~~ ids) (the $in translation).

sum/average return wrong or empty results

They use a find projection, not $group; use an aggregation pipeline through the raw handle.

Second unique index fails with an index-name conflict

All driver-created unique indexes are named "unique". Create extra indexes with createIndex(named:keys:).

unsupportedFeatureByServer from transaction

Standalone mongod, or the cluster has not connected yet. Use a replica set and run a query first.

Lost reviews under concurrent writes

Read-modify-write on an embedded array. Use an atomic $push through the raw handle or a separate collection.

A required @Field added later fails to decode old documents

There is no schema; backfill in a migration before deploying the model.

isConstraintFailure never true

The driver always returns false. Handle insertFailed, or insert through MongoKitten and inspect the write errors.

Version notes

  • fluent-mongo-driver: 1.0.0 (2020-06-16) for Vapor 4 with Fluent 4; 1.3.0 (2023-05) raised the minimum Swift version and platforms; 1.4.0 (2024-10-06) is the "Update For Swift 6" release (tools version 5.9, StrictConcurrency=complete, FluentKit 1.49 or later, MongoKitten 6.7.13 or later). Nothing newer exists as of 2026-10-17, and there is no Vapor-5-specific release of it; whether it builds with Vapor 5.0.0-beta.4 was not tested.

  • Vapor 3 to 4: packages written for Vapor 3’s Fluent are not compatible with Vapor 4; the Vapor 4 model is Model with property wrappers and DatabaseID.mongo.

  • Vapor 5 (see What’s New, Upgrading and Vapor 5) is in beta; the Fluent MongoDB driver’s compatibility and maintenance outlook are unknown. Plan to isolate the dependency.

  • Official drivers: MongoDB Inc.'s Swift driver and mongodb-vapor stopped development in 2023 (both repositories archived), so the option of replacing the community driver with the vendor’s no longer exists; the other current route is MongoKitten 7 directly.

Where the books are outdated

  • Patel (Hands-On Full-Stack Development with Swift, Packt, 2018, chapter 4) builds a MongoDB-backed server on Vapor 2, with a provider package for MongoDB, Droplet configuration through JSON files and a server whose access control is off. None of that exists in Vapor 4: the integration is Fluent plus FluentMongoDriver, configured in configure(_:) with app.databases.use(.mongo(connectionString:), as: .mongo), and the Vapor 2 Provider mechanism is replaced by LifecycleHandler, app.storage and database drivers (see Services, Storage and Dependency Injection). Enable authentication and TLS rather than copying the book’s local setup. The idea that a provider package wraps a database driver survives; the code does not.

  • Condon et al. (Server-Side Swift with Vapor, Kodeco), as summarised in the bibliography, covers MongoDB as one of the Fluent drivers in its early Fluent chapters but builds its application on SQL databases, and its later chapters are Vapor 3 EventLoopFuture code. The differences are those of the other Fluent pages: property-wrapper models, AsyncMigration and async queries. The book predates the 2023 archiving of the official Swift driver, so it cannot tell you the driver landscape described at the top of this page.