SQLKit and Raw SQL

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; Vapor 4.122.2 itself needs tools version 6.0), with Fluent 4.13.0, FluentKit 1.57.0, SQLKit 3.36.0, fluent-sqlite-driver 4.9.0 and fluent-postgres-driver 2.14.0, 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 that also enables the template’s upcoming-feature flags (ExistentialAny, InternalImportsByDefault, MemberImportVisibility). The SQLKit code was run against SQLite (an in-memory database) and exercised with curl; the output shown is captured from those runs, including the SQL text and bound values that the driver logged at debug level. The PostgreSQL code (the PostgresDatabase cast, pg_notify, LISTEN and the JSONB query) was only compiled: it was not run against a live PostgreSQL server, and the behaviour described for it comes from the sources of postgres-nio 1.33 and fluent-postgres-driver 2.14 and from the official documentation. Vapor 5 is at 5.0.0-beta.4 and is not released yet; SQLKit itself is a separate package that does not change with Vapor’s major version, and where Vapor 5 changes how you reach it that is noted. The page extends the Bookshelf service of the earlier pages of this section, using the canonical model set (authors, books, reviews, users, tags, book_tag, loans); the one page-local addition is an index created by a migration. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable. The same topic in Rust is in Databases with SQLx, and transactions as a general pattern in Migrations and Transactions. Models, relations and the Fluent query builder are covered by Fluent Overview and Database Drivers, Fluent Models, Fluent Relations and Fluent Queries and Pagination; this page starts where they stop.

Why leave Fluent for SQL

Fluent is a portable query builder over models. That portability is also its limit: a model query always selects whole rows of one table (plus eagerly loaded relations), and anything a particular database does better than the lowest common denominator has no spelling in the Fluent API. Typical reasons to drop to SQL in a service like Bookshelf:

  • Reports: an author leaderboard with GROUP BY, HAVING, several joins and computed columns returns rows that are no model. Doing it in Fluent means loading models and aggregating in Swift.

  • Set-based writes: UPDATE books SET price_cents = price_cents * 90 / 100 WHERE ... changes thousands of rows in one statement, where Fluent would load, mutate and save each model (or make a .set query per field).

  • Database features: RETURNING, ON CONFLICT, window functions, common table expressions, full-text search, JSONB operators, LISTEN and NOTIFY, advisory locks.

  • Schema details: partial or expression indexes, triggers, extensions, enum alterations.

Fluent already contains the escape hatches that the official documentation names, and it is built on SQLKit for every SQL driver, so the next layer down is the same code path Fluent uses: when you call SQLKit you are not bypassing Fluent’s connection pool, logging, transaction or event loop, you are using the same database object through another interface.

SQLKit, FluentSQL and the drivers

Four packages are involved, and knowing which is which explains most import errors:

Package (version on this page) Role

sql-kit (3.36.0)

The database-independent SQL library: the SQLDatabase protocol, SQLQueryString, the expression types (SQLSelect, SQLBind, SQLIdentifier, SQLColumn, …​), the query builders and SQLRow decoding. It has no driver and no Fluent dependency.

fluent-kit (1.57.0), module FluentSQL

The bridge. It adds the Fluent-aware pieces on top of SQLKit: SQLRow.decode(fluentModel:), the .sql(...) helpers for Fluent filters, sorts, schema fields and constraints, and the conformance of Fluent’s logging-override database to SQLDatabase.

sqlite-kit, postgres-kit, mysql-kit

Per-database SQLKit drivers: the dialect (quoting, placeholders, supported syntax), the connection and the row type. They are dependencies of the Fluent drivers.

fluent-sqlite-driver (4.9.0), fluent-postgres-driver (2.14.0), fluent-mysql-driver (4.8.0)

Fluent drivers. Their database objects conform to both Database (Fluent) and SQLDatabase (SQLKit), which is what makes the cast in the next section work.

MongoDB is the exception: fluent-mongo-driver is not SQL, so req.db as? any SQLDatabase is nil there, and this page does not apply to it (see MongoDB).

The Package.swift dependency is the same as for Fluent; SQLKit arrives transitively through the Fluent driver. Add it as a direct dependency only when the target imports SQLKit itself and you want the build to fail loudly if the transitive dependency goes away:

// swift-tools-version:6.3
import PackageDescription

let package = Package(
    name: "App",
    platforms: [.macOS(.v13)],
    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-sqlite-driver.git", from: "4.9.0"),
        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.14.0"),
        .package(url: "https://github.com/vapor/sql-kit.git", from: "3.36.0"),
    ],
    targets: [
        .executableTarget(name: "App", dependencies: [
            .product(name: "Vapor", package: "vapor"),
            .product(name: "Fluent", package: "fluent"),
            .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
            .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
            .product(name: "SQLKit", package: "sql-kit"),
        ]),
    ]
)

(The scratch package for this page pins every package with exact:; from: is the usual choice in an application.) With MemberImportVisibility enabled, which the official template turns on, each file must import the module that declares what it uses: a file that calls req.db needs import Fluent even though FluentSQL is imported, because db is declared in the Fluent module. The compiler says "property 'db' is not available due to missing import of defining module 'Fluent'".

Where it sits

SQLKit is not a stage of the request pipeline. A handler asks the request for its database, and the same pooled connection machinery that serves Fluent queries serves SQLKit statements. What changes is only which interface you hold.

Fluent, SQLKit and the driver API as three views of the same database object, all serialized by the driver dialect into SQL text plus bound values

Three consequences are used throughout the page:

  • the object behind req.db is one value that conforms to several protocols; "casting" selects an interface, it does not open a second connection;

  • SQLKit statements serialize through the driver’s SQLDialect, so the same builder code produces "title" quoting and ?1 placeholders on SQLite, "title" and $1 on PostgreSQL and backquotes and ? on MySQL, while a string of raw SQL is sent as written, in that database’s own dialect;

  • values you bind never travel inside the SQL text: they are protocol parameters. That is what makes SQLKit safe by default and what unsafeRaw turns off.

Getting a SQLDatabase

The official documentation shows the cast (here with the Bookshelf authors table, and the Fluent fallback for a non-SQL driver):

import Fluent
import FluentSQL
import Vapor

struct AuthorName: Decodable {
    var name: String
}

func authorNames(_ req: Request) async throws -> [String] {
    if let sql = req.db as? any SQLDatabase {
        // The underlying database driver is SQL.
        let authors = try await sql.raw("SELECT name FROM authors ORDER BY name").all(decoding: AuthorName.self)
        return authors.map(\.name)
    } else {
        // The underlying database driver is _not_ SQL.
        return try await Author.query(on: req.db).sort(\.$name).all().map(\.name)
    }
}

Any Fluent Database can be cast: req.db, app.db, req.db(.psql) for a named database, the database parameter of a migration and the database handed to a transaction closure. In Swift 6 with ExistentialAny the protocol is written any SQLDatabase (the docs' older spelling as? SQLDatabase produces an error under that flag). Because a handler needs this all the time and the cast can fail, wrap it once. The listing also defines the two Codable structs used by later examples; they are not Fluent models, because a report row has no table.

import Fluent
import FluentSQL
import Vapor

extension Request {
    /// The request's database as a SQLKit database, or a 500 when the configured driver is not SQL.
    var sql: any SQLDatabase {
        get throws {
            guard let sql = self.db as? any SQLDatabase else {
                throw Abort(.internalServerError, reason: "The default database is not a SQL database")
            }
            return sql
        }
    }
}

/// A row of the reporting query: it is not a Fluent model, so it is a plain Codable struct.
struct BookRow: Content {
    var id: UUID
    var title: String
    var author: String
    var priceCents: Int
    var publishedYear: Int

    enum CodingKeys: String, CodingKey {
        case id, title, author
        case priceCents = "price_cents"
        case publishedYear = "published_year"
    }
}

struct AuthorStats: Content {
    var author: String
    var books: Int
    var reviews: Int
    var averageRating: Double?

    enum CodingKeys: String, CodingKey {
        case author, books, reviews
        case averageRating = "average_rating"
    }
}

struct PriceChange: Content {
    var id: UUID
    var priceCents: Int

    enum CodingKeys: String, CodingKey {
        case id
        case priceCents = "price_cents"
    }
}

Things the listing relies on:

  • SQLDatabase is Sendable, so the value can cross isolation boundaries and be captured by @Sendable closures. The any SQLDatabase you obtain is a small struct that wraps the Fluent driver’s database together with the request’s logger, so the query log lines carry the request id automatically (visible in the logs below).

  • A non-SQL driver makes the cast fail; failing with a 500 Abort is right, because it is a configuration error and not a client error. If the service must run on both a SQL driver and MongoDB, keep the SQL code behind a protocol and give the Mongo implementation its own.

  • Content on the structs gives JSON encoding for the response; decoding a row only needs Decodable.

  • CodingKeys map snake_case columns to Swift names. SQLKit also has a SQLRowDecoder key strategy (convertFromSnakeCase), covered below.

Raw SQL with SQLQueryString

db.raw(_:) takes a SQLQueryString, a type that conforms to ExpressibleByStringInterpolation. A string literal passed to raw becomes a SQLQueryString, and each \(...) interpolation inside it is not string interpolation at all: it is a call to one of the custom appendInterpolation methods of SQLQueryString, which decide how the value is added to the statement. This is the entire safety mechanism of raw SQL in SQLKit, so the table lists all of them:

Interpolation Effect

\(bind: value)

Adds a placeholder (?1, $1, ?) and sends value as a bound parameter. value is any Encodable & Sendable. Use for every value that is data.

\(binds: [a, b, c])

One placeholder per element, separated by commas: for IN (...) lists.

\(ident: "title")

A quoted identifier ("title" or backquoted), with the quote character escaped. Use for table or column names. It makes a name syntactically safe, not semantically allowed: it will quote any text, so still check names that come from a client.

\(idents: ["a", "b"], joinedBy: ", ")

A list of quoted identifiers.

\(literal: 42), \(literal: "text")

Writes a number or string into the statement text as a SQL literal (strings are quoted and escaped with the dialect’s rules). Prefer bind:; use literal: where the grammar does not allow a parameter (LIMIT in some databases, DDL default values).

\(literals: [...], joinedBy: ...)

A list of literals.

\(true) / \(false) (a Bool)

The dialect’s boolean literal.

\(expression)

Embeds any SQLExpression, for example a SQLBind, SQLColumn or a subquery built by a builder.

\(unsafeRaw: text)

Pastes text into the statement unchanged. No quoting, no escaping, no placeholders. This is the SQL injection interpolation; it is covered in its own section below.

In older SQLKit releases \(raw:) was the name of what is now \(unsafeRaw:); the old spelling is still present in 3.36.0 as a deprecated overload that points to the new name, and the rename exists so the dangerous one is visible in review.

The first route is a search by title. The user’s text becomes a bound value; the % wildcards are added in Swift, to the value, not in the SQL:

import Fluent
import FluentSQL
import Vapor

func searchRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // Raw SQL with safe interpolation: \(bind:) turns the value into a bound parameter.
    sql.get("search") { req async throws -> [BookRow] in
        let q = try req.query.get(String.self, at: "q")
        return try await req.sql.raw("""
            SELECT b.id, b.title, a.name AS author, b.price_cents, b.published_year
            FROM books b JOIN authors a ON a.id = b.author_id
            WHERE b.title LIKE \(bind: "%" + q + "%")
            ORDER BY b.title
            """).all(decoding: BookRow.self)
    }
}

Source: sql-kit README

Running it (the first call is the search for The):

$ curl -s 'http://localhost:8091/sql/search?q=The'
[{"price_cents":1499,"title":"The Dispossessed","published_year":1974,"author":"Ursula K. Le Guin","id":"460CCD56-7622-47A8-9200-2A6C6918CA7D"},{"price_cents":1599,"title":"The Fellowship of the Ring","published_year":1954,"author":"J. R. R. Tolkien","id":"D404897B-6E7E-44D0-997E-8A78557F0183"},{"price_cents":1299,"title":"The Hobbit","published_year":1937,"author":"J. R. R. Tolkien","id":"F737BF67-7EEB-4932-A2F2-F14F09EF487A"}]

With --log debug the driver logs what it sent, and the value is a parameter, not part of the text:

[ DEBUG ] Executing query [binds: [%Hobbit%], database-id: sqlite, request-id: 0D879DF8-..., sql: SELECT b.id, b.title, a.name AS author, b.price_cents, b.published_year ...

Two details worth knowing:

  • raw is a builder, like the others: nothing is sent until a terminal call (run(), all(), first(), all(decoding:), first(decoding:)) is awaited. A raw(...) whose result is dropped silently does nothing.

  • raw also has an overload taking a plain String variable. It is deprecated, because a String that you built yourself is exactly what injection looks like. Only a string literal (or a SQLQueryString) is accepted without a warning; keep it that way.

Decoding rows

A SQLRow is one result row. The fetching terminals give you either rows or decoded values:

Call Result

.run()

Executes and discards rows (inserts, updates, DDL without RETURNING).

.all() / .first()

[any SQLRow] / (any SQLRow)?.

.all(decoding: T.self) / .first(decoding: T.self)

Decodes each row with SQLRowDecoder into any Decodable type. first returns nil for no rows.

.all(decodingColumn: "n", as: Int.self)

One column of every row, for scalar queries (SELECT COUNT(*)).

.run { row in ... }

Streams rows to a closure without building the array, for large result sets.

row.decode(column: "id", as: UUID.self)

One column of one row.

row.decode(model: T.self)

A whole row into a Decodable. For Fluent models use row.decode(fluentModel: Book.self) from FluentSQL.

Columns are matched to coding keys by name, which is why the report queries alias computed columns (AVG(r.rating) AS average_rating) and why BookRow declares CodingKeys. An alternative to CodingKeys is a decoder strategy; the all(decoding:prefix:keyDecodingStrategy:userInfo:) overloads accept .convertFromSnakeCase, which maps price_cents to priceCents. CodingKeys is explicit and survives renames; the strategy is shorter and fails at runtime when a column is missing.

Decoding into Fluent models goes through decode(fluentModel:): the column names must be the model’s field keys, which SELECT * from the model’s table satisfies. This is useful for a hand-written query that selects whole rows, such as a query with a dialect-specific WHERE:

import Fluent
import FluentSQL
import Vapor

func modelRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // SQL rows decoded into a Fluent model: column names must be the model's field keys.
    sql.get("published-models") { req async throws -> [String] in
        let rows = try await req.sql.raw("""
            SELECT * FROM books WHERE status = \(bind: BookStatus.published) ORDER BY title
            """).all()
        return try rows.map { try $0.decode(fluentModel: Book.self).title }
    }
}

The model comes back with its fields filled and exists set, so save() on it issues an UPDATE. Relations are not loaded ($author.value is nil); eager loading is a Fluent feature, not a SQL one. With BookStatus.published interpolated by bind:, the enum is encoded as its String raw value, matching what Fluent stored.

Two behaviours of the SQLite driver show in the columns: Fluent’s .datetime columns are stored as REAL (seconds as a floating-point number), and Date values you bind are encoded the same way, which is why due_at < \(bind: now) in the transaction example compares correctly. UUIDs are stored as text. On PostgreSQL the same code uses native timestamptz and uuid columns. Binding a Date and comparing it with a Fluent-created column works on both because both sides are encoded by the same driver.

The query builders

For the shapes of statement you write often, builders are better than raw text: they quote identifiers for the dialect, bind values automatically and compose, so a dynamic filter does not need string concatenation. The entry points are methods on SQLDatabase:

Entry point Builder and main methods

db.select()

SQLSelectBuilder: column, columns, from, join, where, orWhere, groupBy, having, orderBy, limit, offset, distinct, union, with/CTEs, locking clauses.

db.insert(into:)

SQLInsertBuilder: columns, values, model, models, onConflict, ignoringConflicts, returning.

db.update(_:)

SQLUpdateBuilder: set, set(model:), where, returning.

db.delete(from:)

SQLDeleteBuilder: where, returning.

db.create(table:), alter, drop, create(index:), drop(index:), create(enum:)

Schema builders (DDL).

db.raw(...)

SQLRawBuilder, the raw statement.

A where has several spellings; mixing them is the main source of compile errors. where("name", .equal, value) takes a column name and an Encodable value and binds it. where(SQLColumn(...), .equal, SQLBind(value)) takes two expressions (SQLExpression values), so a value has to be wrapped in SQLBind yourself (the compiler error "argument type 'Int' does not conform to expected type 'SQLExpression'" is what you get when a bare Int is passed with an SQLColumn). The comparison operators are SQLBinaryOperator values such as .equal, .lessThan, .greaterThanOrEqual, .like, .in, .is.

The next route is a dynamic list: optional filters are added only when the client sent them, the sort column is chosen from an allow-list and the direction is a typed value. It returns the BookRow struct from earlier.

import Fluent
import FluentSQL
import Vapor

struct BookQuery: Content {
    var minYear: Int?
    var maxPriceCents: Int?
    var sort: String?
    var descending: Bool?
}

func bookListRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // Builder: dynamic filters, an allow-listed sort column and a join.
    sql.get("books") { req async throws -> [BookRow] in
        let query = try req.query.decode(BookQuery.self)
        let sortable: Set<String> = ["title", "price_cents", "published_year"]
        let sortColumn = query.sort ?? "title"
        guard sortable.contains(sortColumn) else {
            throw Abort(.badRequest, reason: "Cannot sort by \(sortColumn)")
        }
        var select = try req.sql.select()
            .column(SQLColumn("id", table: "books"))
            .column(SQLColumn("title", table: "books"))
            .column(SQLColumn("name", table: "authors"), as: "author")
            .column(SQLColumn("price_cents", table: "books"))
            .column(SQLColumn("published_year", table: "books"))
            .from("books")
            .join("authors", on: SQLColumn("author_id", table: "books"), .equal, SQLColumn("id", table: "authors"))
        if let minYear = query.minYear {
            select = select.where(SQLColumn("published_year", table: "books"), .greaterThanOrEqual, SQLBind(minYear))
        }
        if let maxPrice = query.maxPriceCents {
            select = select.where(SQLColumn("price_cents", table: "books"), .lessThanOrEqual, SQLBind(maxPrice))
        }
        let direction: SQLDirection = (query.descending ?? false) ? .descending : .ascending
        return try await select
            .orderBy(SQLColumn(sortColumn, table: "books"), direction)
            .all(decoding: BookRow.self)
    }
}

Output and the SQL that the builder produced (note ?1, ?2 and the join the builder wrote):

$ curl -s 'http://localhost:8091/sql/books?minYear=1950&maxPriceCents=1500'
[{"id":"AEB16D3C-A724-4CAF-80A9-CA10C121FBBD","author":"Ursula K. Le Guin","price_cents":1199,"published_year":1968,"title":"A Wizard of Earthsea"},{"id":"EE1CDF1E-F183-4AD5-AB4B-A4BB276CDE5A","author":"Ursula K. Le Guin","price_cents":1499,"published_year":1974,"title":"The Dispossessed"}]

$ curl -s -w ' [%{http_code}]\n' 'http://localhost:8091/sql/books?sort=title;DROP'
{"error":true,"reason":"Cannot sort by title;DROP"} [400]
binds: [1950, 1500], sql: SELECT "books"."id", "books"."title", "authors"."name" AS "author", "books"."price_cents", "books"."published_year" FROM "books" INNER JOIN "authors" ON "books"."author_id" = "authors"."id" WHERE "books"."published_year" >= ?1 AND "books"."price_cents" <= ?2 ORDER BY "books"."title" ASC

The sort column is the one thing in the statement that is not a bound value: orderBy(SQLColumn(sortColumn, ...)) quotes it as an identifier, which stops it from becoming a second statement, but a client could still name any column, including ones you do not want to expose. Hence the allow-list. Identifiers cannot be bound in SQL, so an allow-list (a Set or an enum) is the correct control.

Aggregations are where raw SQL is often clearer than the builder. The leaderboard below joins three tables and groups; the HAVING threshold is bound:

import Fluent
import FluentSQL
import Vapor

func reportRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // Aggregation that the Fluent query builder cannot express in one statement.
    sql.get("authors", "stats") { req async throws -> [AuthorStats] in
        try await req.sql.raw("""
            SELECT a.name AS author,
                   COUNT(DISTINCT b.id) AS books,
                   COUNT(r.id) AS reviews,
                   AVG(r.rating) AS average_rating
            FROM authors a
            LEFT JOIN books b ON b.author_id = a.id
            LEFT JOIN reviews r ON r.book_id = b.id
            GROUP BY a.id, a.name
            HAVING COUNT(DISTINCT b.id) >= \(bind: 1)
            ORDER BY average_rating DESC, a.name
            """).all(decoding: AuthorStats.self)
    }
}

Source: sql-kit README

$ curl -s http://localhost:8091/sql/authors/stats
[{"reviews":3,"average_rating":4,"author":"J. R. R. Tolkien","books":2},{"reviews":2,"average_rating":4,"author":"Ursula K. Le Guin","books":2}]

(Unpublished Author has no books and is excluded by the HAVING clause. JSON object keys come out in no particular order because Content encodes through a dictionary-backed encoder; clients must not depend on it. average_rating is 4 and not 4.0 because JSON has one number type.)

Writing: insert, update, delete, returning and upsert

Writes use the same builders. update and delete without a where affect every row, exactly as in SQL; SQLKit does not protect you from that. The discount route is a set-based update with an expression on the right-hand side and RETURNING:

import Fluent
import FluentSQL
import Vapor

func discountRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // UPDATE ... RETURNING through the builder, with an expression on the right-hand side.
    sql.post("books", ":bookID", "discount") { req async throws -> PriceChange in
        let bookID = try req.parameters.require("bookID", as: UUID.self)
        let percent = try req.query.get(Int.self, at: "percent")
        guard (1...90).contains(percent) else { throw Abort(.badRequest, reason: "percent must be 1...90") }
        let changed = try await req.sql.update("books")
            .set(SQLColumn("price_cents"), to: SQLQueryString("price_cents * \(literal: 100 - percent) / 100"))
            .where("id", .equal, bookID)
            .returning("id", "price_cents")
            .first(decoding: PriceChange.self)
        guard let changed else { throw Abort(.notFound) }
        return changed
    }
}
$ curl -s -X POST 'http://localhost:8091/sql/books/D30B9415-DD3E-4132-8905-4ED8672E1663/discount?percent=10'
{"id":"D30B9415-DD3E-4132-8905-4ED8672E1663","price_cents":1169}
binds: [D30B9415-DD3E-4132-8905-4ED8672E1663], sql: UPDATE "books" SET "price_cents" = price_cents * 90 / 100 WHERE "id" = ?1 RETURNING "id", "price_cents"

1299 * 90 / 100 is 1169 in integer arithmetic (the fraction is truncated, as in the database), which is the number shown. returning is available on insert, update and delete when the dialect’s supportsReturning is true: SQLite 3.35 and later, PostgreSQL and MariaDB 10.5 and later. MySQL does not have it, and the builder will produce SQL the server rejects; read db.dialect.supportsReturning when a service must run on both.

An INSERT can take an Encodable value as the row with model(_:); the property names are the column names. Despite the name, this is not Fluent’s Model: the SQLKit documentation says Fluent models are not compatible with model(), so declare a small Encodable struct for the columns you write. Combined with onConflict this gives an upsert, in a single statement that returns the id of either the new row or the existing one:

import Fluent
import FluentSQL
import Vapor

/// An Encodable value (not a Fluent model) whose property names are the column names.
struct NewTag: Encodable {
    var id: UUID
    var name: String
}

struct TagID: Content {
    var id: UUID
}

func tagRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // Upsert: INSERT ... ON CONFLICT DO UPDATE ... RETURNING, with an Encodable value as the row.
    sql.put("tags") { req async throws -> TagID in
        let name = try req.query.get(String.self, at: "name")
        let row = try await req.sql.insert(into: "tags")
            .model(NewTag(id: UUID(), name: name))
            .onConflict(with: "name") { $0.set(excludedValueOf: "name") }
            .returning("id")
            .first(decoding: TagID.self)
        guard let row else { throw Abort(.internalServerError) }
        return row
    }

    // A plain INSERT that violates the unique index: the driver error says it is a constraint failure.
    sql.post("tags") { req async throws -> HTTPStatus in
        let name = try req.query.get(String.self, at: "name")
        do {
            try await req.sql.insert(into: "tags").model(NewTag(id: UUID(), name: name)).run()
        } catch let error as any DatabaseError where error.isConstraintFailure {
            throw Abort(.conflict, reason: "Tag \(name) already exists")
        }
        return .created
    }
}
$ for m in PUT PUT POST POST; do curl -s -w ' [%{http_code}]\n' -X $m 'http://localhost:8091/sql/tags?name=fantasy'; done
{"id":"40465C01-2F7F-4F70-9C5D-5F84B4DEB9D3"} [200]
{"id":"40465C01-2F7F-4F70-9C5D-5F84B4DEB9D3"} [200]
{"error":true,"reason":"Tag fantasy already exists"} [409]
{"error":true,"reason":"Tag fantasy already exists"} [409]

The first PUT inserts; the second hits the unique index on name, takes the DO UPDATE branch (a no-op assignment, so that RETURNING yields the existing row) and answers with the same id. The POST handler does a plain INSERT and turns the unique violation into a 409. The generated SQL was INSERT INTO "tags" ("id", "name") VALUES (?1, ?2) ON CONFLICT ("name") DO UPDATE SET "name" = EXCLUDED."name" RETURNING "id". ignoringConflicts(with:) (DO NOTHING) is the alternative when you do not need the id; it returns no row on a conflict, so first(decoding:) would be nil.

The catch clause uses Fluent’s DatabaseError protocol (isSyntaxError, isConstraintFailure, isConnectionClosed), which the SQLite, PostgreSQL and MySQL driver error types conform to, so one catch serves all three databases. Without that check the error would escape as a 500 whose log line contains the driver’s text. It is the same technique used with Fluent queries; see Error Handling for turning errors into responses.

Multi-row inserts and the IN list use the same binding mechanism. binds: expands to one placeholder per element:

import Fluent
import FluentSQL
import Vapor

func statusRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // IN (...) with one bound parameter per value.
    sql.get("by-status") { req async throws -> [String] in
        let statuses = try req.query.get([String].self, at: "status")
        guard !statuses.isEmpty else { throw Abort(.badRequest) }
        struct Row: Decodable { var title: String }
        let rows = try await req.sql.raw("SELECT title FROM books WHERE status IN (\(binds: statuses)) ORDER BY title")
            .all(decoding: Row.self)
        return rows.map(\.title)
    }
}
$ curl -s 'http://localhost:8091/sql/by-status?status=draft&status=archived'
["The Dispossessed"]

The statement was SELECT title FROM books WHERE status IN (?1, ?2) ORDER BY title with binds: [draft, archived]. An empty list would produce IN (), which is a syntax error in most databases, hence the guard. The number of bound parameters is limited by the database (SQLite’s default SQLITE_MAX_VARIABLE_NUMBER is 32766 since 3.32, PostgreSQL’s protocol allows 65535); a client-supplied list should be bounded by validation before it gets here.

Transactions and sessions

Fluent owns the transaction: req.db.transaction { ... } checks one connection out of the pool, begins, runs the closure and commits or rolls back. The database passed to the closure is the transaction’s database, and it is also a SQLDatabase, so the SQLKit statements issued on it run inside the transaction. The important rule is the reverse one: a statement issued on req.db or on a SQLDatabase obtained before the transaction runs on a different pooled connection, outside it.

import Fluent
import FluentSQL
import Vapor

struct LoanClosed: Content {
    var closed: Int
}

func loanRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // A transaction: the closure receives a Fluent Database that is also a SQLDatabase.
    sql.post("loans", "close-overdue") { req async throws -> LoanClosed in
        let now = Date()
        let closed = try await req.db.transaction { tx async throws -> Int in
            guard let sql = tx as? any SQLDatabase else { throw Abort(.internalServerError) }
            let due = try await sql.raw("SELECT id FROM loans WHERE returned_at IS NULL AND due_at < \(bind: now)").all()
            for row in due {
                let id = try row.decode(column: "id", as: UUID.self)
                try await sql.update("loans").set("returned_at", to: now).where("id", .equal, id).run()
            }
            return due.count
        }
        return LoanClosed(closed: closed)
    }
}
$ curl -s -w ' [%{http_code}]\n' -X POST http://localhost:8091/sql/loans/close-overdue
{"closed":1} [200]
$ curl -s -w ' [%{http_code}]\n' -X POST http://localhost:8091/sql/loans/close-overdue
{"closed":0} [200]

(The seed data has one overdue and one current loan.) The loop updates row by row to keep the example readable; in practice one UPDATE loans SET returned_at = ... WHERE returned_at IS NULL AND due_at < ... does it in a single statement, and the transaction is for work that must be atomic across several statements.

SQLDatabase has its own session API, withSession(_:), for the case where you hold a SQLKit database and need several statements on one connection without a Fluent transaction, for example to set a session variable or take an advisory lock and then query. The portable example below just reads twice on the same connection:

import SQLKit
import Vapor

struct Counts: Content {
    var authors: Int
    var books: Int
}

func countsOnOneConnection(_ db: any SQLDatabase) async throws -> Counts {
    try await db.withSession { session in
        let authors = try await session.raw("SELECT COUNT(*) AS n FROM authors")
            .first(decodingColumn: "n", as: Int.self) ?? 0
        let books = try await session.raw("SELECT COUNT(*) AS n FROM books")
            .first(decodingColumn: "n", as: Int.self) ?? 0
        return Counts(authors: authors, books: books)
    }
}

Called from a route as countsOnOneConnection(try req.sql):

$ curl -s http://localhost:8091/sql/counts
{"books":4,"authors":3}

The closure is @Sendable and the connection is returned to the pool when it finishes; do not let session escape. A withSession is not a transaction (there is no BEGIN); on the PostgreSQL driver, when the database already holds a connection (as the database inside a transaction does), withSession reuses that connection instead of checking out another one (fluent-postgres-driver 2.14 sources).

Mixing SQL into Fluent

You do not have to choose one layer for a whole request. Fluent exposes the SQL escape hatches as .sql(...) helpers in FluentSQL, usable wherever Fluent accepts a .custom value:

  • in filter: .filter(.sql(embed: "LOWER(...) LIKE \(bind: x)")), a predicate with bound values; .sql(unsafeRaw:) for a literal fragment;

  • in the schema builder: .field("name", .sql(unsafeRaw: "VARCHAR(64)"), .required) and .sql(embed:) for data types, constraints and defaults (.sql(raw:) is the old name of .sql(unsafeRaw:), deprecated in fluent-kit 1.57.0);

  • in the migration itself, by casting the migration’s database.

import Fluent
import FluentSQL
import Vapor

func mixedRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // A raw predicate inside a Fluent query: the rest of the query stays portable and typed.
    sql.get("fluent-mixed") { req async throws -> [String] in
        let needle = try req.query.get(String.self, at: "q").lowercased()
        let books = try await Book.query(on: req.db)
            .filter(.sql(embed: "LOWER(\(ident: "title")) LIKE \(bind: "%" + needle + "%")"))
            .sort(\.$title)
            .all()
        return books.map(\.title)
    }
}
$ curl -s 'http://localhost:8091/sql/fluent-mixed?q=THE%20h'
["The Hobbit"]

The rest of the query stays Fluent: typed sort, model decoding, eager loading would all still work, and only the predicate is SQL. When the underlying driver is not SQL the .sql(...) filter is meaningless; guard with req.db is any SQLDatabase as the official documentation does.

Migrations are the other common place. Fluent’s schema builder covers tables, columns and unique constraints; an index on two columns, a partial index, a trigger or a Postgres extension is plain SQL (or SQLKit DDL builders) behind the same cast. The migration below is a page-local addition to the canonical set:

import Fluent
import FluentSQL

/// Page-local migration: an index that the Fluent schema builder cannot express portably is created with SQLKit.
struct AddBookReportingIndex: AsyncMigration {
    func prepare(on database: any Database) async throws {
        guard let sql = database as? any SQLDatabase else { return }
        try await sql.create(index: "idx_books_author_year")
            .on("books")
            .column("author_id")
            .column("published_year")
            .run()
    }

    func revert(on database: any Database) async throws {
        guard let sql = database as? any SQLDatabase else { return }
        try await sql.drop(index: "idx_books_author_year").run()
    }
}

The migration log shows CREATE INDEX "idx_books_author_year" ON "books" ("author_id", "published_year"). Because the guard ... else { return } makes it a no-op on a non-SQL driver, the same migration list can be shared with a MongoDB configuration. Production schema changes have more to consider than syntax (locking, online index creation); the Migrations & Schema and Transactions, Model Middleware & Soft Delete pages and the Axum page linked in the introduction cover the process side.

The dialect and the final SQL

Every SQLKit database exposes its dialect and can serialize any expression without running it, which is the quickest way to see what a builder produces and the basis for unit-testing generated SQL:

import Fluent
import FluentSQL
import Vapor

func dialectRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // The dialect and the final SQL text: serialize() shows what the driver sends.
    sql.get("explain") { req async throws -> [String: String] in
        let db = try req.sql
        let query = db.select().column("title").from("books")
            .where("price_cents", .lessThan, 1500).where("status", .equal, "published").query
        let (text, binds) = db.serialize(query)
        return ["dialect": db.dialect.name, "sql": text, "binds": "\(binds)"]
    }
}
$ curl -s http://localhost:8091/sql/explain
{"dialect":"sqlite","sql":"SELECT \"title\" FROM \"books\" WHERE \"price_cents\" < ?1 AND \"status\" = ?2","binds":"[1500, \"published\"]"}

On PostgreSQL the same builder code serializes with $1 and $2 and the server receives two parameters; on MySQL the placeholders are ?. The dialect object also answers capability questions: supportsReturning, supportsIfExists, enumSyntax, upsertSyntax, supportsAutoIncrement. Code that must run on more than one database branches on those instead of on the driver type.

SQL injection safety

The preceding sections give the rule: values go through bind:, binds: or a builder’s Encodable parameters; identifiers go through ident: or SQLColumn and come from an allow-list; unsafeRaw is for text you wrote. The cost of breaking the rule is easiest to see by running it. This route is kept in the scratch project as the anti-pattern; it interpolates the client’s text with unsafeRaw:

import Fluent
import FluentSQL
import Vapor

func unsafeRoutes(_ app: Application) {
    let sql = app.grouped("sql")

    // The anti-pattern, kept to show what it does: the client's text becomes part of the statement.
    sql.get("unsafe-search") { req async throws -> [String] in
        let q = try req.query.get(String.self, at: "q")
        struct Row: Decodable { var title: String }
        let rows = try await req.sql.raw("SELECT title FROM books WHERE title LIKE '%\(unsafeRaw: q)%' AND status = 'published'").all(decoding: Row.self)
        return rows.map(\.title)
    }
}
$ curl -s -G http://localhost:8091/sql/unsafe-search --data-urlencode "q=Hobbit"
["The Hobbit"]
$ curl -s -G http://localhost:8091/sql/unsafe-search --data-urlencode "q=zzz' OR status = 'draft' OR title LIKE '"
["The Hobbit","The Fellowship of the Ring","A Wizard of Earthsea","The Dispossessed"]
$ curl -s -G http://localhost:8091/sql/unsafe-search --data-urlencode "q=' UNION SELECT email FROM users --"
["A Wizard of Earthsea","The Dispossessed","The Fellowship of the Ring","The Hobbit","reader@example.com"]

The driver logged the statements as binds: [] with the client’s quote characters part of the text, e.g. SELECT title FROM books WHERE title LIKE '%' UNION SELECT email FROM users --%' AND status = 'published'. The second request returns the draft book that the status = 'published' condition was supposed to hide; the third returns every title and a column from a different table, a user’s email. The safe route with the same attack string:

$ curl -s -G http://localhost:8091/sql/search --data-urlencode "q=zzz' OR status = 'draft' OR title LIKE '"
[]

The logged statement has binds: [%zzz' OR status = 'draft' OR title LIKE '%]: the quotes are data and nothing matches.

A short checklist for reviewing code that touches SQLKit:

  • every \(unsafeRaw: in the diff: the argument is a constant or one of a closed set of values you control;

  • every \(literal: with a String: could it be bind:? (literal: escapes with the dialect’s rules, which is correct but a second line of defence at best);

  • every \(ident: or SQLIdentifier/SQLColumn whose name came from a request: is it checked against an allow-list?;

  • no raw(someStringVariable): the deprecation warning is the signal;

  • LIKE patterns: the % and _ wildcards inside the user’s text still act as wildcards when bound. Binding prevents injection, not wildcard abuse; escape them (ESCAPE '\') when exact matching matters.

Driver-specific APIs

Everything above is portable across the SQL drivers. When you need something one database offers, cast further, as the official documentation shows: req.db as? any PostgresDatabase (from FluentPostgresDriver, which re-exports PostgresKit and PostgresNIO), the matching protocols for MySQL (MySQLDatabase) and SQLite (SQLiteDatabase). The listing below was compiled against fluent-postgres-driver 2.14.0 and postgres-nio 1.33.1 and is not run against a live server.

import Fluent
import FluentPostgresDriver
import FluentSQL
import PostgresNIO
import Vapor

/// Compile-only: needs a live PostgreSQL server.
struct PostgresDemo {
    /// Cast to the driver-specific protocol and run a simple (text protocol) query.
    static func version(_ req: Request) async throws -> String {
        guard let postgres = req.db as? any PostgresDatabase else {
            throw Abort(.internalServerError, reason: "Not PostgreSQL")
        }
        // Legacy bridge: simpleQuery returns EventLoopFuture and has no async overload on PostgresDatabase.
        let rows = try await postgres.simpleQuery("SELECT version()").get()
        return try rows.first?.decode(String.self) ?? "unknown"
    }

    /// JSONB: store a document and query it with the containment operator.
    static func tagged(_ req: Request, key: String, value: String) async throws -> [String] {
        let sql = try req.sql
        let filter = "{\"\(key)\": \"\(value)\"}"
        struct Row: Decodable { var title: String }
        return try await sql.raw("""
            SELECT title FROM books WHERE metadata @> \(bind: filter)::jsonb ORDER BY title
            """).all(decoding: Row.self).map(\.title)
    }

    /// Publishing a notification is plain SQL on any pooled connection.
    static func notify(_ req: Request, channel: String, payload: String) async throws {
        try await req.sql.raw("SELECT pg_notify(\(bind: channel), \(bind: payload))").run()
    }

    /// Listening needs one dedicated connection that lives as long as the listener.
    static func listen(on channel: String, app: Application) async throws {
        struct MissingDatabasePassword: Error {}
        // Production must provide the password; other environments may fall back to the local placeholder.
        let password: String
        if let fromEnvironment = Environment.get("DATABASE_PASSWORD") {
            password = fromEnvironment
        } else if app.environment == .production {
            throw MissingDatabasePassword()
        } else {
            password = "placeholder"
        }
        let configuration = PostgresConnection.Configuration(
            host: Environment.get("DATABASE_HOST") ?? "localhost",
            port: 5432,
            username: Environment.get("DATABASE_USERNAME") ?? "vapor_username",
            password: password,
            database: Environment.get("DATABASE_NAME") ?? "vapor_database",
            tls: .disable
        )
        let connection = try await PostgresConnection.connect(
            on: app.eventLoopGroup.next(), configuration: configuration, id: 1, logger: app.logger
        )
        do {
            try await connection.listen(on: channel) { notifications in
                for try await notification in notifications {
                    app.logger.info("notification", metadata: ["channel": "\(channel)", "payload": "\(notification.payload)"])
                }
            }
        } catch {
            try? await connection.close()
            throw error
        }
        try await connection.close()
    }
}

What each function relies on:

  • version: PostgresDatabase.simpleQuery uses the text protocol, with no parameters, and is the documented spelling for a driver-specific query. It returns EventLoopFuture, bridged with .get(): a legacy bridge, the only one on this page. PostgresNIO’s newer async API is on PostgresConnection and PostgresClient, not on this legacy protocol, and listen below uses it (the async overload of PostgresConnection.connect).

  • tagged (JSONB): the @> operator tests containment of one JSON document in another and can use a GIN index. The filter is built as a JSON string in Swift and bound, then cast with ::jsonb; a column holding JSON would be a page-local addition (metadata), because the canonical books table has none. Fluent can also store a Codable property in a JSON column; SQLKit is the way to use operators that Fluent has no name for. The JSON text is built with string interpolation here to keep the example short; for arbitrary input build the document with JSONEncoder instead.

  • notify: pg_notify(channel, payload) is an ordinary function call, so it runs on a pooled connection with bound arguments, and it can be issued inside a transaction (the notification is delivered at commit, and not at all on rollback).

  • listen: LISTEN registers interest for the session, so it needs one connection that lives as long as the listener. Holding a pooled Fluent connection for that would starve the pool, so the example opens a dedicated PostgresConnection and iterates the PostgresNotificationSequence that listen(on:consume:) provides; leaving the closure sends UNLISTEN. The configuration comes from environment variables, not from literals in code; the password falls back to a local placeholder only outside the production environment, where a missing DATABASE_PASSWORD stops the listener before it connects. A production listener needs reconnect logic, because the connection (and the subscription) is lost with it, and notifications sent while it was down are not replayed.

Notification payloads are limited to 8000 bytes in PostgreSQL; send an identifier and have the receiver query for the data. The PostgreSQL side is also not transactional with the Swift side: an application that must not lose events should use a queue (Queues & Background Jobs) and treat NOTIFY as a wake-up hint.

Testing code that uses SQLKit

An in-memory SQLite database (app.databases.use(.sqlite(.memory), as: .sqlite)) makes SQLKit code testable without a server (see Testing), as the runs on this page show. It tests your statements' logic, row decoding and the HTTP layer; it does not test PostgreSQL syntax such as @>, ON CONFLICT ... DO UPDATE variants or RETURNING on MySQL. Use a SQLite test for portable SQLKit code, and a real PostgreSQL (a Docker container started by CI) for anything that uses PostgresDatabase or a Postgres function. db.serialize(_:) lets you assert on the SQL text and bound values of a builder without a database, for statements where the shape matters (the explain route returns exactly that).

Swift 6 and concurrency notes

  • SQLDatabase is a Sendable protocol. The query builders are classes that you configure and run within one task; do not share a half-built builder between tasks.

  • The onRow closure of run { row in ... } and the withSession closure are @Sendable; do not capture a var from the surrounding function in them. Collect results through the return value or an actor.

  • Bound values must be Encodable & Sendable: a class instance that is not Sendable fails to compile with "type does not conform to protocol 'Sendable'". Pass the field you need (a UUID, a String) instead of the model.

  • Fluent models are @unchecked Sendable in this section, so passing Book through a @Sendable closure compiles, but mutating a shared instance from two tasks is still a data race the compiler cannot see.

  • ExistentialAny requires any SQLDatabase in signatures, and MemberImportVisibility requires import Fluent in files that use req.db (see the first section).

  • The Fluent drivers expose EventLoopFuture-based legacy APIs (simpleQuery, PostgresDatabase.withConnection); bridge them with .get() or makeFutureWithTask, never block with .wait() inside an async function.

Common errors and pitfalls

Symptom Cause and fix

req.db as? any SQLDatabase is nil

The default database is MongoDB, or the database id you meant is not the default. Use req.db(.psql) for a named database.

"argument type 'Int' does not conform to expected type 'SQLExpression'"

A bare value passed where an expression is required, typically with SQLColumn as the first argument. Wrap it: SQLBind(value); or pass a column name string as the first argument.

"property 'db' is not available due to missing import of defining module 'Fluent'"

MemberImportVisibility; add import Fluent to the file.

Rows decode but a field is missing, or "Missing key"

Column name differs from the coding key (price_cents vs priceCents). Add CodingKeys or alias the column in the query (AS).

SQL error: near "..." syntax error (SQLite) or syntax error at or near (PostgreSQL)

Raw SQL in the wrong dialect, for example $1 placeholders written by hand on SQLite, or a trailing comma. Use \(bind:), which writes the correct placeholder for the driver.

raw did nothing

No terminal call was awaited. Add .run(), .all() or .first().

UPDATE or DELETE hit every row

where was omitted or a conditional where did not run. Assert the affected rows with returning, or branch before building the statement.

Statements in a transaction are not rolled back

They ran on req.db, not on the transaction’s database; cast the closure’s parameter.

Constraint violation is a 500

Catch DatabaseError and test isConstraintFailure; map to 409 (see the unique-tag example).

unsafeRaw in a code review

Replace with bind: (a value), ident: plus an allow-list (a name) or a SQLExpression (a fragment).

LISTEN stops delivering after a database restart

The dedicated connection is gone; reconnect and re-listen, and reconcile state that may have been missed.

RETURNING fails on MySQL

The dialect has no RETURNING; read db.dialect.supportsReturning or reselect after the write.

Version notes

  • SQLKit 3.x is the line used by Vapor 4 (Fluent 4). In 3.36.0 the sources mark appendInterpolation(raw:) (renamed to unsafeRaw:), raw(_: String) and the string-condition where overloads as deprecated in favour of SQLQueryString and expressions; they still compile, with warnings. When each deprecation was introduced was not checked.

  • SQLDatabase.withSession(_:) is a protocol requirement in 3.36.0 (the protocol provides a default implementation); a custom SQLDatabase conformance should be re-checked against the release it targets.

  • Database.transaction in Fluent 4.13 still has the closure shape shown above; the Fluent AsyncMigration and the async query terminals are the supported way to write new code.

  • Vapor 5 (5.0.0-beta.4, see What’s New, Upgrading and Vapor 5) depends on SQLKit through the same Fluent packages. Fluent for Vapor 5 is expected to move to the async-first, structured-concurrency drivers; the SQLKit builders and SQLQueryString interpolations on this page do not depend on Vapor itself, so they are the part of the code most likely to carry over unchanged. The EventLoopFuture driver protocols (PostgresDatabase.simpleQuery) are the part most likely to change. No Fluent release for Vapor 5 is published yet, so check the one you target before relying on that.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco) lists raw SQL among the Fluent topics of its chapter 30, written for Vapor 4’s first releases. Code in that style uses EventLoopFuture chains; in 4.122 the SQLKit terminals have async throws overloads (all(decoding:), first(decoding:), run()), the cast is written as? any SQLDatabase, and \(raw:) is deprecated in favour of \(unsafeRaw:).

  • Yeung and Patel (Packt) were written for Vapor 3 and Vapor 2. SQLKit as the shared SQL layer under Fluent drivers is a Vapor 4 arrangement, so their raw-query code does not carry over: the current spelling is db.raw(SQLQueryString) with bind: on a Fluent driver’s database.

  • Tutorials that call PostgresDatabase.query(...) with [PostgresData] arrays use the legacy PostgresNIO API; the newer PostgresNIO API is PostgresQuery through PostgresClient or PostgresConnection, and SQLKit is the portable layer.