Migrations & Schema

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, fluent-kit 1.57.0, fluent-sqlite-driver 4.9.0 and the PostgreSQL 2.14.0, MySQL 4.8.0 and MongoDB 1.4.0 drivers, 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. The commands were run for real with swift run App migrate, --revert and --auto-migrate against a SQLite file database, and the output shown is captured from those runs (abridged where stated). The PostgreSQL, MySQL and MongoDB specifics come from the official documentation and the driver sources; they were not run against a live server, and the page says so where it matters. Fluent for Vapor 5 has no release at the time of writing (Vapor itself is at 5.0.0-beta.4), so any change to its migration API is not yet published.

The page uses the Bookshelf models of the earlier pages (Author, Book, Review, User, Tag, BookTag, Loan). Columns that exist only to teach a technique, such as books.slug and loans.fine_cents, are marked "page-local addition" and do not belong to the canonical model set. Swift itself is covered by the Swift Reference, and async functions and Sendable by Async/Await and Tasks and Actors, Isolation and Sendable. The same topic in Rust is in Migrations & Transactions, and database-independent strategies are in the site’s Schema Evolution section.

What a migration is and why Fluent has them

A Fluent model (see Fluent Models and Fluent Overview and Database Drivers) describes the shape an application expects. It does not create anything: Book maps to a table called books, and if no such table exists the first query fails. A migration is the code that makes the database match the model, and the code that moves it from one shape to the next without losing data. Each migration is one named, ordered step with two halves: prepare applies it and revert undoes it. Fluent records which migrations have run in a table it manages itself, so the same code base can start a brand-new database, an old one, and a database halfway between, and end up in the same place.

Three properties follow and run through the page:

  • Schema and models are decoupled on purpose. The schema builder names tables and columns with strings, never with key paths, so a migration written two years ago still compiles after the model changed.

  • A migration that has run is history. Never edit it; add a new one. The recorded name is the identity.

  • Migrations are code you run, not a declarative description. Fluent does not compare models with the database. What the database looks like is exactly what the sequence of migrations did to it.

Where it sits

Migrations are not part of the request pipeline. They run at boot or deploy time, before the server accepts traffic (or in a separate process that never serves), and requests only ever see the finished schema.

flowchart LR subgraph reg["configure: app.migrations.add, in order"] direction TB r1["CreateBookshelf"] --> r2["SeedDemoCatalog (not production)"] --> r3["AddBookSlug"] --> r4["AddLoanFine"] --> r5["AddBookSlugIndex"] end reg --> cmd{"App migrate
or serve --auto-migrate"} cmd -->|"unprepared migrations only"| b1["Batch 1: CreateBookshelf, SeedDemoCatalog"] b1 -->|"next deploy"| b2["Batch 2: AddBookSlug, AddLoanFine, AddBookSlugIndex"] b2 --> log[("_fluent_migrations: name and batch")] b2 -->|"migrate --revert"| rev["reverts batch 2 only, newest first"]

The picture shows what the rest of the page spells out. configure registers the full list on every start. The command compares that list with _fluent_migrations, prepares only the missing ones, and stamps them all with the next batch number. --revert undoes the most recent batch, in reverse order of registration.

The migration protocol

A migration is a type with a prepare and a revert method. For code that uses async/await, conform to AsyncMigration:

// FluentKit 1.57.0, Concurrency/AsyncMigration.swift (the future-based Migration it refines is the legacy form)
public protocol AsyncMigration: Migration {
    func prepare(on database: any Database) async throws
    func revert(on database: any Database) async throws
}
// An extension implements the two future-returning requirements of Migration by running the async ones in a task.
// The name requirement comes from Migration, whose default implementation is String(reflecting: Self.self).

Points that matter in practice:

  • Migration is the original protocol: prepare(on:) and revert(on:) return EventLoopFuture<Void>. It still exists, and AsyncMigration is a layer over it, so app.migrations.add takes both. Write new migrations as AsyncMigration; the future-based form is legacy and is only needed when reading older code.

  • Migration is Sendable. A migration is a value that Fluent keeps in a list and calls from a task it chooses. Make it a struct with Sendable stored properties (a cheap, immutable value such as a Bool or a String), never a class holding mutable state.

  • database is the connection the migrator gives you. Use it, not req.db or app.db: a migration has no request, and using another handle can run the statement on a different connection than the one inside a transaction.

  • revert should undo prepare and nothing else. If a step cannot be undone (dropped data), say so by throwing from revert rather than by silently doing nothing.

The migration name

Fluent identifies a migration by name, defaulting to the fully qualified type name, for example App.CreateBookshelf. That string is what _fluent_migrations stores, so:

  • renaming the type, or moving it to another module, changes the name. Fluent then considers the new name unprepared and runs it again on a database where it already ran. Either never rename a migration type, or pin the name with var name: String { "CreateBookshelf" } from the start (the AddBookSlug listing below does, and the captured output shows the shorter name);

  • a migration type declared private or inside a function has an unstable default name. The migrator refuses it: for a name that contains unknown context at it logs a critical message and stops the process with the fatal error "Private migrations not allowed" (the checks are in Migrator.preventUnstableNames in fluent-kit 1.57.0). Declare migrations internal or public.

Registering migrations and their order

Migrations live in app.migrations. Add them in configure, in the order they must run:

import Fluent
import FluentSQLiteDriver
import Vapor

func configure(_ app: Application) async throws {
    // BOOKSHELF_DB_FILE is read from the environment; a relative file name is created in the working directory.
    let file = Environment.get("BOOKSHELF_DB_FILE") ?? "bookshelf.sqlite"
    app.databases.use(.sqlite(.file(file)), as: .sqlite)

    // The default is .info; lower it to .debug or raise it to .warning to change how chatty the [Migrator] lines are.
    app.fluent.migrationLogLevel = .info

    // Order matters: a migration may depend on every migration registered before it.
    app.migrations.add(CreateBookshelf())
    if app.environment != .production {
        app.migrations.add(SeedDemoCatalog())
    }
    app.migrations.add(AddBookSlug())
    app.migrations.add(AddLoanFine())
    app.migrations.add(AddBookSlugIndex())

    registerExperiment(app)

    app.get("books") { req async throws -> [String] in
        try await Book.query(on: req.db).sort(\.$title).all().map(\.title)
    }
}
  • app.migrations.add(_:to:) takes one migration, a variadic list or an array, and an optional DatabaseID. Without to: the migration belongs to the default database; app.migrations.add(m, to: .analytics) targets a second database registered with app.databases.use(_:as:). The migrate output prints the target next to each name (on <default> above).

  • Order is the dependency order. A migration that creates a foreign key to authors has to run after the one that creates authors; add order is run order, and revert walks it backwards. Fluent does not infer dependencies.

  • Registration is cheap and must be the same every time. configure registers the whole history on every start; the command decides what to run. Conditional registration, such as the app.environment != .production check around the seed above, is allowed, with one caution: a migration that was registered when it ran must stay registered, or revert cannot find it. Registering it only outside production is safe because production never records it.

  • app.fluent.migrationLogLevel (a Logger.Level, default .info) controls the [Migrator] Starting prepare and Finished prepare lines. Failures are always logged at .error.

The Bookshelf schema as one migration

The canonical Bookshelf schema is created by one migration. Enums come first because columns reference their data type; tables follow in dependency order; revert drops in the opposite order. This is the first listing the migrator runs in every output below.

import Fluent

struct CreateBookshelf: AsyncMigration {
    func prepare(on db: any Database) async throws {
        let status = try await db.enum("book_status").case("draft").case("published").case("archived").create()
        let role = try await db.enum("user_role").case("reader").case("librarian").case("admin").create()
        try await db.schema("authors").id().field("name", .string, .required).field("bio", .string).create()
        try await db.schema("users").id().field("email", .string, .required).field("password_hash", .string, .required)
            .field("role", role, .required).unique(on: "email").create()
        try await db.schema("books").id().field("title", .string, .required).field("isbn", .string, .required)
            .field("published_year", .int, .required).field("price_cents", .int, .required)
            .field("status", status, .required)
            .field("author_id", .uuid, .required, .references("authors", "id", onDelete: .cascade))
            .field("created_at", .datetime).field("updated_at", .datetime).unique(on: "isbn").create()
        try await db.schema("reviews").id().field("rating", .int, .required).field("body", .string, .required)
            .field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
            .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
            .field("created_at", .datetime).create()
        try await db.schema("tags").id().field("name", .string, .required).unique(on: "name").create()
        try await db.schema("book_tag").id()
            .field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
            .field("tag_id", .uuid, .required, .references("tags", "id", onDelete: .cascade))
            .unique(on: "book_id", "tag_id").create()
        try await db.schema("loans").id()
            .field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
            .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
            .field("loaned_at", .datetime, .required).field("due_at", .datetime, .required)
            .field("returned_at", .datetime).create()
    }
    func revert(on db: any Database) async throws {
        for s in ["loans", "book_tag", "tags", "reviews", "books", "users", "authors"] { try await db.schema(s).delete() }
        try await db.enum("user_role").delete()
        try await db.enum("book_status").delete()
    }
}

Things to read out of it:

  • db.enum("book_status")...create() returns the DatabaseSchema.DataType that the later field("status", status, .required) uses. On PostgreSQL this creates a native enum type; on SQLite the column is stored as TEXT (the real schema printed "status" TEXT NOT NULL) and the call still returns a usable data type. MySQL was not run.

  • .references("authors", "id", onDelete: .cascade) (relations are covered in Fluent Relations) is a foreign key constraint declared on the field. The cascade runs in the database: deleting an author deletes their books and, through the chain, their reviews, loans and tag links, without Fluent running model middleware or soft-delete logic on those rows.

  • .unique(on: "isbn") is a table-level constraint. book_tag gets the composite .unique(on: "book_id", "tag_id"), which prevents attaching the same tag to a book twice.

  • revert deletes children before parents. Reversing the order raises a foreign key error on drivers that enforce them.

Running migrations

Vapor’s Application registers a migrate command when Fluent is installed. It takes no arguments of its own besides --revert:

swift run App migrate --help
Usage: .build/out/Products/Debug/App migrate [--revert]

Prepare or revert your database migrations

Flags:
  revert n/a

(The usage path is printed absolute and shortened here; the "n/a" description is what the 4.13.0 build prints.)

migrate

swift run App migrate creates _fluent_migrations if it does not exist, lists the registered migrations that are not recorded in it, shows them with +, asks for confirmation, runs them in registration order and records every one with the same batch number: the highest existing batch plus one. -y or --yes is a flag of the Vapor console, not of migrate, and answers every confirmation with yes (needed in CI; without a terminal the prompt aborts the process with "Received EOF on stdin"). The captured run below is two deploys of Bookshelf: the first creates the schema and a development seed, the second adds slug, fine_cents and the unique slug index.

$ swift run App migrate
Migrate Command: Prepare
The following migration(s) will be prepared:
+ App.CreateBookshelf on <default>
+ App.SeedDemoCatalog on <default>
Would you like to continue?
y/n> yes
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.SeedDemoCatalog]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.SeedDemoCatalog]
Migration successful
$ swift run App migrate
Migrate Command: Prepare
The following migration(s) will be prepared:
+ AddBookSlug on <default>
+ App.AddLoanFine on <default>
+ App.AddBookSlugIndex on <default>
Would you like to continue?
y/n> yes
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.AddBookSlugIndex]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.AddBookSlugIndex]
Migration successful
$ swift run App migrate
Migrate Command: Prepare
No new migrations.

$ sqlite3 final.sqlite "SELECT name, batch FROM _fluent_migrations ORDER BY created_at"
App.CreateBookshelf|1
App.SeedDemoCatalog|1
AddBookSlug|2
App.AddLoanFine|2
App.AddBookSlugIndex|2
$ sqlite3 final.sqlite "SELECT title, slug FROM books"
The Dispossessed|the-dispossessed-3fb8fb6b
A Wizard of Earthsea|a-wizard-of-earthsea-2ed60bb5
$ sqlite3 final.sqlite ".indexes books"
uq_books_slug

The first run was made with the second group of app.migrations.add calls absent; the second run is the full list from the listing above. Fluent skipped the two migrations that were already recorded and put the other three in batch 2. A third run printed "No new migrations."

The slug column of the two seeded books was filled by the AddBookSlug backfill (see below), which is the point of the two-batch run: the data from the first deploy was in the table when the second deploy changed it.

migrate --revert

--revert undoes the last batch only, in reverse registration order, and removes those names from _fluent_migrations. Repeated, it walks back through the batches one at a time:

$ swift run App migrate --revert --yes
Migrate Command: Revert
The following migration(s) will be reverted:
- App.AddBookSlugIndex on <default>
- App.AddLoanFine on <default>
- AddBookSlug on <default>
Would you like to continue?
y/n> yes
[ INFO ] [Migrator] Starting revert [database-id: sqlite, migration: App.AddBookSlugIndex]
[ INFO ] [Migrator] Finished revert [database-id: sqlite, migration: App.AddBookSlugIndex]
[ INFO ] [Migrator] Starting revert [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Finished revert [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Starting revert [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Finished revert [database-id: sqlite, migration: AddBookSlug]
Migration successful
App.CreateBookshelf|1
App.SeedDemoCatalog|1
CREATE TABLE "loans" ("id" UUID PRIMARY KEY, "book_id" UUID NOT NULL REFERENCES "books" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, "loaned_at" REAL NOT NULL, "due_at" REAL NOT NULL, "returned_at" REAL);
CREATE TABLE "books" ("id" UUID PRIMARY KEY, "title" TEXT NOT NULL, "isbn" TEXT NOT NULL, "published_year" INTEGER NOT NULL, "price_cents" INTEGER NOT NULL, "status" TEXT NOT NULL, "author_id" UUID NOT NULL REFERENCES "authors" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, "created_at" REAL, "updated_at" REAL, CONSTRAINT "uq:books.isbn" UNIQUE ("isbn"));
$ swift run App migrate --revert --yes
Migrate Command: Revert
The following migration(s) will be reverted:
- App.SeedDemoCatalog on <default>
- App.CreateBookshelf on <default>
Would you like to continue?
y/n> yes
[ INFO ] [Migrator] Starting revert [database-id: sqlite, migration: App.SeedDemoCatalog]
[ INFO ] [Migrator] Finished revert [database-id: sqlite, migration: App.SeedDemoCatalog]
[ INFO ] [Migrator] Starting revert [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Finished revert [database-id: sqlite, migration: App.CreateBookshelf]
Migration successful
_fluent_migrations
$ swift run App migrate --revert --yes
Migrate Command: Revert
No migrations to revert.

Note that the second migrate --revert reverted SeedDemoCatalog and then CreateBookshelf (batch 1) and that the third reported nothing left to revert. _fluent_migrations itself remains.

--auto-migrate and --auto-revert

Both flags belong to every Vapor command, not only to migrate; they run the migrations before the command, without asking:

swift run App serve --auto-migrate
swift run App serve --auto-revert     # reverts ALL batches, not just the last

Captured output of serve --auto-migrate against an empty SQLite file, followed by a request:

[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.SeedDemoCatalog]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.SeedDemoCatalog]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.AddBookSlugIndex]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.AddBookSlugIndex]
[ NOTICE ] Server started on http://127.0.0.1:8089
[ INFO ] GET /books [request-id: 8B975A00-494B-44A4-A7FE-189D05FE82FF]
$ curl -s http://127.0.0.1:8089/books
["A Wizard of Earthsea","The Dispossessed"]

In code, try await app.autoMigrate() and try await app.autoRevert() do the same. Note the asymmetry in the 4.13.0 sources: autoRevert() calls revertAllBatches(), while the interactive migrate --revert reverts only the last batch. autoRevert() drops everything, which is exactly what a test wants and exactly what a production server must never run.

_fluent_migrations

The migrator stores one row per prepared migration in _fluent_migrations (MigrationLog in fluent-kit): id, name (unique), batch, created_at, updated_at. It is an ordinary table you can read:

sqlite3 bookshelf.sqlite "SELECT name, batch FROM _fluent_migrations ORDER BY created_at"

Do not edit it by hand, except to repair a rename of a migration type: updating name to the new string stops Fluent from re-running it.

Where migrations should run in production

  • As an explicit step of the deployment, App migrate --yes (see Commands; as a Compose job in Deployment with Docker; the wider rule in Production and Performance), before the new version starts serving. The step can fail the release before any user is affected.

  • serve --auto-migrate is convenient on a laptop or in a single-instance preview environment. With several replicas it has every replica start the same migration at the same moment. The migrator in fluent-kit 1.57.0 takes no lock and does no coordination: two processes can both read the list of unprepared migrations and both run the first one.

  • Make each migration compatible with the code of the previous release (see expand and contract below), so the schema can change while the old version is still serving.

  • Keep revert for development and for tests. A production rollback of a destructive change cannot return the data the forward change removed.

The SchemaBuilder

database.schema("books") returns a SchemaBuilder. The name matches the model’s static let schema. You chain methods and finish with exactly one of three actions.

Action Meaning

create()

Creates the table. field, id() and the constraint methods apply; deleteField, updateField and the delete-constraint methods do not. If the table exists the call throws, unless you add ignoreExisting().

update()

Changes an existing table: adds fields and constraints, updates and deletes fields, deletes constraints.

delete()

Drops the table. Nothing else applies.

Fields and data types

.id() adds the standard id column of type uuid as the primary key, matching @ID(key: .id). Any other column is .field(name, type, constraints...), where the name is the database key ("published_year"), the same string as in the model’s @Field(key:). The built-in data types:

Data type Swift type it stores

.string

String

.int8, .int16, .int32, .int64, .uint8 …​ .uint64

fixed-width integers; .int is an alias of .int64 and .uint of .uint64

.bool

Bool

.datetime

Date with time of day (use this for @Timestamp)

.date

Date without time of day

.float, .double

Float, Double

.data

Data

.uuid

UUID

.dictionary, .dictionary(of:)

nested dictionaries and Codable structs (JSON columns on SQL drivers); keys are strings

.array, .array(of:)

nested arrays

.enum(...)

a native enum, obtained from database.enum("name").read() or the value returned by create()

Field constraints

  • .required makes the column NOT NULL. Omit it for an optional property: bio is @OptionalField, so it has no .required.

  • .references(table, field, onDelete:onUpdate:) is a foreign key declared on the column.

  • .identifier(auto:) marks a primary key that is not the default UUID (.field("id", .int, .identifier(auto: true))); auto says whether the database generates the value.

  • .sql(...) hands a driver-specific constraint to SQL drivers. .sql(.default(0)) is the portable default value. A free-form fragment is .sql(unsafeRaw: "CHECK (helpful_votes >= 0)"). .sql(raw:), which older articles show, still compiles but is deprecated in fluent-kit 1.57.0 ("Renamed to .sql(unsafeRaw:)`") and produces a compiler warning. The `unsafe in the name is a reminder that the string goes into the statement as written: never build it from user input.

/// A column with a default and a raw CHECK constraint (page-local reviews.helpful_votes).
struct AddReviewHelpfulVotes: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("reviews")
            .field("helpful_votes", .int, .required, .sql(.default(0)), .sql(unsafeRaw: "CHECK (helpful_votes >= 0)"))
            .update()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("reviews")
            .deleteField("helpful_votes")
            .update()
    }
}

The migration above ran on SQLite and produced "helpful_votes" INTEGER NOT NULL DEFAULT 0 CHECK (helpful_votes >= 0).

Table constraints, unique and foreign keys

Constraints that span more than one column are declared on the builder, not on a field:

  • .unique(on: "a", "b") requires the combination to be unique; unique(on:name:) gives it a name you can remove later.

  • .foreignKey("author_id", references: "authors", "id", onDelete: .cascade, name: "fk_books_author") is the table-level form of references. It is the form update() accepts when you add a key to a table that already exists.

  • ForeignKeyAction has .noAction (the default), .restrict, .cascade, .setNull and .setDefault. Pick the action by what the domain wants: a deleted author should take their books with them (.cascade), a deleted moderator should not delete reviews (.setNull, which needs a nullable column).

  • The actions run in the database, so Fluent’s model middleware, @Timestamp(on: .delete) soft delete and deleted filters never see the cascaded rows. Where the Transactions, Model Middleware & Soft Delete page says "soft delete", do not combine it with .cascade and expect the children to be soft-deleted too.

  • Removing: .deleteUnique(on:), .deleteForeignKey(name:), .deleteConstraint(name:).

/// A foreign key added after the fact, with an explicit name and ON DELETE SET NULL.
struct AddReviewModerator: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("reviews")
            .field("moderator_id", .uuid)
            .foreignKey("moderator_id", references: "users", "id", onDelete: .setNull, name: "fk_reviews_moderator")
            .update()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("reviews")
            .deleteForeignKey(name: "fk_reviews_moderator")
            .deleteField("moderator_id")
            .update()
    }
}

This listing compiles and is correct for PostgreSQL and MySQL, which can add a foreign key to an existing table. On SQLite it fails: the same update() printed "SQLite only supports adding columns in ALTER TABLE statements." in the real run. SQLite can only add foreign keys at create() time, which is why the canonical migration declares them there.

Enums

A native enum is created, extended and dropped with database.enum(name):

/// Add a case to a native enum (PostgreSQL), then use it. Existing enum values keep working.
struct AddBookStatusRetired: AsyncMigration {
    func prepare(on database: any Database) async throws {
        _ = try await database.enum("book_status")
            .case("retired")
            .update()
    }

    func revert(on database: any Database) async throws {
        _ = try await database.enum("book_status")
            .deleteCase("retired")
            .update()
    }
}

case("name") adds a value, deleteCase("name") removes one, update() applies the changes, create() makes the enum and delete() drops it; read() returns the data type of an enum that already exists. The retired migration above applied on SQLite without error because SQLite has no native enum type, so nothing was altered; on PostgreSQL it issues ALTER TYPE ... ADD VALUE (not run on PostgreSQL for this page). Removing a case on PostgreSQL requires that no row uses it. Matching Swift side: the BookStatus raw enum must get the new case before any code writes it, and the @Enum property fails to decode a value it does not know.

Dictionary and array columns, defaults

/// Structured columns. SQLite accepts one ADD COLUMN per ALTER TABLE, so each field gets its own update().
struct AddBookExtras: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("books").field("keywords", .array(of: .string)).update()
        try await database.schema("books").field("attributes", .dictionary(of: .string)).update()
        try await database.schema("books").field("in_stock", .bool, .required, .sql(.default(true))).update()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("books").deleteField("in_stock").update()
        try await database.schema("books").deleteField("attributes").update()
        try await database.schema("books").deleteField("keywords").update()
    }
}

.array(of: .string) and .dictionary(of: .string) become JSON columns on SQL drivers (the SQLite schema printed "keywords" JSON, "attributes" JSON). The migration deliberately issues three update() calls, one per column: the first attempt put the three field calls in one update() and SQLite rejected the statement with near ",", so on SQLite each added column needs its own update(). Doing it per column is also valid on PostgreSQL and MySQL.

Spaces

database.schema("planets", space: "mirror_universe") puts a table in a schema (PostgreSQL) or database (MySQL) other than the default; the model must set static let space to match. This is the only place the schema builder and the model must agree on something other than a name.

What each database can do

Fluent generates one DDL dialect per driver. The table is the part that decides which migrations are portable:

Operation SQLite (run) PostgreSQL / MySQL (docs, not run) Consequence

Add a column

works, one column per ALTER TABLE

works

one update() per column is always safe

Delete a column

works on SQLite 3.35+ unless the column is part of a UNIQUE or other constraint ("error in table books after drop column")

works

drop the constraint first, which SQLite cannot do

updateField (change a column type)

fails: "SQLite only supports adding columns in ALTER TABLE statements."

works

change the type with add, copy, delete, or recreate the table

Add a foreign key or unique constraint to an existing table

fails with the same message

works

declare them at create(), or use a unique index (below)

Add an enum case

no-op (no native enum)

ALTER TYPE on PostgreSQL

keep the Swift enum and the database in step

Transactional DDL

yes (the whole database.transaction rolled back)

yes on PostgreSQL; no on MySQL, where DDL commits implicitly

do not rely on rollback of a failed MySQL migration

The real runs behind the SQLite column were:

  • updateField("published_year", .int16) failed with "SQLite only supports adding columns in ALTER TABLE statements.";

  • .foreignKey(...) in an update() failed with the same message;

  • .unique(on: "slug", name: "uq_books_slug") in an update() failed with the same message (captured below);

  • dropping isbn failed with "error in table books after drop column: expressions prohibited in PRIMARY KEY and UNIQUE constraints", because isbn is unique;

  • three field calls in one update() failed with near ",".

A unique index gives the same guarantee as a unique constraint and can be created on an existing table on all three SQL databases, through SQLKit. The listing below is the migration that actually ran in the captured output:

/// Enforce uniqueness once every row has a slug. SQLite cannot add a constraint with ALTER TABLE, but a unique
/// index gives the same guarantee and is portable to PostgreSQL and MySQL through SQLKit.
struct AddBookSlugIndex: AsyncMigration {
    func prepare(on database: any Database) async throws {
        guard let sql = database as? any SQLDatabase else { return }
        try await sql.create(index: "uq_books_slug").on("books").column("slug").unique().run()
    }

    func revert(on database: any Database) async throws {
        guard let sql = database as? any SQLDatabase else { return }
        var drop = sql.drop(index: "uq_books_slug")
        if sql.dialect.name == "mysql" {
            drop = drop.on("books")  // MySQL needs the table; SQLite and PostgreSQL reject ON here
        }
        try await drop.run()
    }
}

Two details came out of running it. create(index:) takes the table through .on("books") on every database. drop(index:) must not carry .on(...) on SQLite and PostgreSQL, where an index name is global in its schema, but MySQL requires it. The first version of this migration used .on("books") in revert unconditionally and failed on SQLite with near "ON": syntax error; the dialect check in the listing is the repair. The first version of prepare used the schema builder and produced this failure (abridged, two duplicate warning lines removed):

$ swift run App migrate --yes
Migrate Command: Prepare
The following migration(s) will be prepared:
+ App.CreateBookshelf on <default>
+ AddBookSlug on <default>
+ App.AddLoanFine on <default>
+ App.AddBookSlugIndex on <default>
+ App.SeedDemoCatalog on <default>
Would you like to continue?
y/n> yes
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.CreateBookshelf]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: AddBookSlug]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Finished prepare [database-id: sqlite, migration: App.AddLoanFine]
[ INFO ] [Migrator] Starting prepare [database-id: sqlite, migration: App.AddBookSlugIndex]
[ ERROR ] [Migrator] Failed prepare [database-id: sqlite, error: SQLite only supports adding columns in ALTER TABLE statements., migration: App.AddBookSlugIndex]
Swift/ErrorType.swift:254: Fatal error: Error raised at top level: SQLite only supports adding columns in ALTER TABLE statements.

Notice the state it leaves behind: CreateBookshelf, AddBookSlug and AddLoanFine are recorded, AddBookSlugIndex is not, and the following migrate run offered only the failed migration and the seed again. A failing migration does not undo the migrations before it, because each migration is committed and recorded on its own.

Evolving a live schema

Every change to a deployed schema follows one pattern, often called expand and contract: add what the new code needs while the old code still works, move the data, switch the code, and only then remove what is no longer used. Each step is its own migration and usually its own release.

Adding a column and backfilling

SQLite and PostgreSQL both refuse to add a NOT NULL column without a default to a table that already has rows. The safe sequence is: add the column nullable, fill it, then tighten it (or add the unique index) in a later migration.

/// Page-local addition: books.slug, added as optional, backfilled, then made unique in a later migration.
struct AddBookSlug: AsyncMigration {
    var name: String { "AddBookSlug" }

    func prepare(on database: any Database) async throws {
        try await database.schema("books")
            .field("slug", .string)
            .update()
        // Backfill existing rows using a model that lives only in this migration.
        let books = try await SlugRow.query(on: database).all()
        for book in books where book.slug == nil {
            book.slug = slugify(book.title) + "-" + String(try book.requireID().uuidString.prefix(8)).lowercased()
            try await book.save(on: database)
        }
    }

    func revert(on database: any Database) async throws {
        try await database.schema("books")
            .deleteField("slug")
            .update()
    }
}

/// A frozen snapshot of the columns this migration touches. It is not the app's `Book` model, so later changes
/// to `Book` cannot change what this migration does.
private final class SlugRow: Model, @unchecked Sendable {
    static let schema = "books"
    @ID(key: .id) var id: UUID?
    @Field(key: "title") var title: String
    @OptionalField(key: "slug") var slug: String?
    init() {}
}

func slugify(_ title: String) -> String {
    title.lowercased()
        .map { $0.isLetter || $0.isNumber ? String($0) : "-" }
        .joined()
        .split(separator: "-", omittingEmptySubsequences: true)
        .joined(separator: "-")
}
  • The backfill uses SlugRow, a model that exists only inside this file, with only the columns the migration needs. The application’s Book model changes over time (it may later drop slug or rename a field); a migration that queried Book would then break or change meaning. A frozen, private model, with its columns spelled as strings, cannot. Note the @unchecked Sendable on the class: Fluent models are mutable reference types, and the compiler cannot prove them safe; they are only touched inside this migration’s task.

  • Backfilling row by row is fine for thousands of rows. For millions, use one set-based statement through SQLKit (see SQLKit and Raw SQL) (UPDATE books SET slug = ...), or batch the loop with range, because a long single transaction holds locks.

  • The backfill and the ALTER TABLE are two statements: if the process stops between them, the column exists but is empty, and the migration is not recorded. A re-run fails with "duplicate column name". Wrap the pair in database.transaction when the database supports transactional DDL (see below), or make each step idempotent.

  • The captured run shows the result: the two books seeded by batch 1 got the-dispossessed-3fb8fb6b and a-wizard-of-earthsea-2ed60bb5. The eight-character suffix is part of the id, which keeps slugs unique without a second query.

Adding a column with a default

When every existing row can take the same value, a database default avoids the backfill. loans.fine_cents is the page-local example:

/// Page-local addition: loans.fine_cents with a database default.
struct AddLoanFine: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("loans")
            .field("fine_cents", .int, .required, .sql(.default(0)))
            .update()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("loans")
            .deleteField("fine_cents")
            .update()
    }
}

.required together with .sql(.default(0)) is allowed on ALTER TABLE ADD COLUMN because the default fills the existing rows. The matching model would declare @Field(key: "fine_cents") var fineCents: Int, and the old release, which does not know the column, keeps working because inserts without it use the default.

Renaming a column

Fluent’s schema builder has no renameField. The portable way is expand and contract inside one migration or across two: add the new column, copy, delete the old one. The listing renames the page-local loans.fine_cents to late_fee_cents:

/// "Rename" loans.fine_cents to loans.late_fee_cents the portable way: add, copy, delete. Both columns are
/// page-local additions. A column that takes part in a UNIQUE constraint cannot be dropped on SQLite.
struct RenameFineToLateFee: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("loans")
            .field("late_fee_cents", .int, .required, .sql(.default(0)))
            .update()
        if let sql = database as? any SQLDatabase {
            try await sql.update("loans").set(SQLColumn("late_fee_cents"), to: SQLColumn("fine_cents")).run()
        }
        try await database.schema("loans").deleteField("fine_cents").update()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("loans")
            .field("fine_cents", .int, .required, .sql(.default(0)))
            .update()
        if let sql = database as? any SQLDatabase {
            try await sql.update("loans").set(SQLColumn("fine_cents"), to: SQLColumn("late_fee_cents")).run()
        }
        try await database.schema("loans").deleteField("late_fee_cents").update()
    }
}

It ran on SQLite (the schema afterwards showed "late_fee_cents" INTEGER NOT NULL DEFAULT 0 and no fine_cents). For a column that is part of a unique constraint, such as books.isbn, the same approach failed on SQLite ("error in table books after drop column"), and it left isbn13 added but unrecorded, which is the half-applied state described above. On PostgreSQL and MySQL you can instead run ALTER TABLE ... RENAME COLUMN through SQLDatabase.raw, which is atomic and keeps the constraint. In a deployment with two app versions running at once, rename in three releases: add the new column and write to both, read from the new one and stop writing the old, then drop the old.

Changing a type

updateField("published_year", .int16) is the schema builder’s way to change a column type (.int is an alias of .int64, so the example narrows the column):

/// Narrow books.published_year from .int (an alias of .int64) to .int16. PostgreSQL and MySQL can do this in place;
/// SQLite cannot.
struct NarrowPublishedYear: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("books")
            .updateField("published_year", .int16)
            .update()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("books")
            .updateField("published_year", .int)
            .update()
    }
}

It is correct for PostgreSQL and MySQL. On SQLite it fails (see the table); there, change a type by adding a new column, copying and dropping, as for a rename, or by creating a new table, copying the rows and swapping names. Changing a type on a large table rewrites it on PostgreSQL and MySQL and blocks writes while it does: schedule it.

Dropping a column or table

deleteField and delete() are the contract step. Remove them in the release after the last code that read them has been retired, never in the same release. Their revert can recreate the column, but not the data.

Seeding

A seed is a migration that inserts rows. That is convenient because it is ordered, recorded and revertible with the rest of the schema, and SeedDemoCatalog shows the usual pattern:

/// Seed data that only exists outside production. Registered conditionally in `configure`.
struct SeedDemoCatalog: AsyncMigration {
    static let books = [("The Dispossessed", "9780061054884", 1974), ("A Wizard of Earthsea", "9780547773742", 1968)]

    func prepare(on database: any Database) async throws {
        let author = Author(name: "Ursula K. Le Guin", bio: "Demo author")
        try await author.save(on: database)
        let authorID = try author.requireID()
        for (title, isbn, year) in Self.books {
            let book = Book(title: title, isbn: isbn, publishedYear: year, priceCents: 1299, status: .published, authorID: authorID)
            try await book.save(on: database)
        }
    }

    func revert(on database: any Database) async throws {
        // `~~` with an array is `IN (...)`: exactly the seeded ISBNs. With a string it would be `LIKE '%...%'`.
        try await Book.query(on: database).filter(\.$isbn ~~ Self.books.map(\.1)).delete()
        try await Author.query(on: database).filter(\.$name == "Ursula K. Le Guin").delete()
    }
}
  • Per environment. The seed is registered only when app.environment != .production, so a production database never receives demo rows, and the captured migrate --env production run listed four migrations and no seed. Environment also has .development and .testing; to seed only in development compare with .development.

  • Data that production needs (a first administrator, a list of roles, reference data) belongs in a migration that runs everywhere. Take its secrets from the environment (Environment.get) and never put a password in the source.

  • A seed migration depends on the schema, so it is registered after the migrations that create the tables it writes to. Here it sits between CreateBookshelf and AddBookSlug, which is why the later backfill filled the seeded books' slug (the captured run). Registered after AddBookSlug, the seed would have to set slug itself.

  • Seeds written as migrations record their name, so they never run twice. A seed that must be re-runnable (fixtures for a test) is better written as a plain async function called from the test.

  • revert for a seed deletes by something that identifies exactly the rows the seed made (here the exact list of seeded ISBNs and the author name), never Book.query(on:).delete() and never a pattern such as \.$isbn ~~ "9780", which is a substring match (LIKE '%9780%') and would delete almost every book.

Transactions

Fluent does not wrap a migration in a transaction. Each prepare runs on the database’s connection with autocommit, and the batch is not atomic: the failure above kept the migrations that had already finished. To make a multi-step migration all or nothing, wrap the steps yourself:

/// Two steps that must succeed or fail together. Fluent does not wrap a migration in a transaction by itself.
struct AddReviewPair: AsyncMigration {
    var failAtTheEnd = false

    func prepare(on database: any Database) async throws {
        try await database.transaction { tx in
            try await tx.schema("reviews").field("pinned", .bool).update()
            try await tx.schema("reviews").field("pinned_at", .datetime).update()
            if failAtTheEnd { throw SimulatedFailure() }
        }
    }

    func revert(on database: any Database) async throws {
        try await database.schema("reviews").deleteField("pinned_at").update()
        try await database.schema("reviews").deleteField("pinned").update()
    }
}

struct SimulatedFailure: Error {}

In the real run the failing variant threw after both columns had been added inside the transaction; afterwards .schema reviews showed neither pinned nor pinned_at, and the migration was not recorded. The successful variant added both. The closure receives tx, a database bound to the transaction; use it for every statement inside the closure, not database.

This works because SQLite and PostgreSQL run DDL inside transactions. MySQL commits implicitly at each DDL statement, so there a failed multi-statement migration stays partially applied no matter how it is written: keep MySQL migrations to one DDL statement each and make backfills idempotent (docs, not run). MongoDB creates collections on first write, so there is little schema to migrate (not run here); see the MongoDB page for what the driver does with schema calls.

Coupling between migrations and models

The model and its migration state the same facts twice: @Field(key: "published_year") in Book, .field("published_year", .int, .required) in the migration. Fluent does not check that they agree.

  • A key spelled differently in the two places fails at the first query with a SQL error ("no such column") or, for an insert, with a NOT NULL violation. Define a key once with an extension of FieldKey if the duplication hurts: extension FieldKey { static var publishedYear: Self { "published_year" } }, and use it in both places.

  • A model property added without a migration fails at runtime, never at compile time.

  • A migration must not depend on model code that can change: use migration-local models for data access (as SlugRow does), or SQLKit, and schema("name") with strings for DDL.

  • The model’s @Enum, @Timestamp(format:) and @OptionalField choices decide whether a column is required; an optional Swift property over a required column fails on nil.

  • Change the model in the same pull request as the migration, and let the CI test below (apply, revert, apply) catch the mismatch.

Condon et al. use the same split: a model per table, one migration per model, registered in dependency order in configure. Chapter 26 changes the schema of a deployed app and versions the API around it; the API Design, Versioning and DTOs page covers the API half.

Testing migrations

The cheapest guard against a broken migration is a test that applies every migration to an empty in-memory database and reverts them all. SQLite keeps it fast:

@testable import App
import Fluent
import FluentSQLiteDriver
import SQLKit
import Testing
import VaporTesting

@Suite("Migrations")
struct MigrationTests {
    /// Builds an application on a private in-memory database with the production migration list.
    private func makeApp() async throws -> Application {
        let app = try await Application.make(.testing)
        app.databases.use(.sqlite(.memory), as: .sqlite)
        app.migrations.add(CreateBookshelf())
        app.migrations.add(AddBookSlug())
        app.migrations.add(AddLoanFine())
        app.migrations.add(AddBookSlugIndex())
        return app
    }

    @Test("every migration applies and reverts cleanly")
    func roundTrip() async throws {
        let app = try await makeApp()
        do {
            try await app.autoMigrate()
            let sql = try #require(app.db as? any SQLDatabase)
            let columns = try await sql.raw("SELECT name FROM pragma_table_info('books')").all(decoding: ColumnName.self)
            #expect(columns.map(\.name).contains("slug"))

            try await app.autoRevert()
            let tables = try await sql.raw("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'books'")
                .all(decoding: ColumnName.self)
            #expect(tables.isEmpty)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }
}

struct ColumnName: Decodable { let name: String }

Real result of swift test (Swift Testing, VaporTesting from Vapor 4.122.2):

✔ Test "every migration applies and reverts cleanly" passed after 0.069 seconds.
✔ Suite "Migrations" passed after 0.069 seconds.
✔ Test run with 1 test in 1 suite passed after 0.069 seconds.

The test uses autoMigrate() and autoRevert() directly, with the same list as configure. A real project would move the list into a function used by both. In-memory SQLite cannot catch PostgreSQL-only mistakes; run the same test against a PostgreSQL container in CI as well (see Testing). The test also verifies something easy to forget: that revert leaves a clean database, which is what lets a developer roll a branch back.

Swift 6 concurrency notes

  • AsyncMigration inherits Sendable. A struct with only Sendable stored properties is enough; a migration with a closure property needs a @Sendable closure.

  • prepare and revert run in whatever task the migrator uses; do not touch main-actor state. Everything they need comes through database.

  • Migration-local Fluent models are mutable classes: declare them @unchecked Sendable, as the Book model does, and keep instances inside the method that created them (see Fluent Models for why Fluent models are not Sendable by construction).

  • try await app.autoMigrate() is async; call it in configure after all add calls and before the server starts. The future-returning autoMigrate() exists but .wait() on it from an async context blocks a thread of the cooperative pool.

Common mistakes

Symptom Cause Fix

"SQLite only supports adding columns in ALTER TABLE statements."

updateField, .foreignKey, .unique(on:) or deleteConstraint in update() on SQLite

declare constraints in create(), use a unique index, or copy into a new column or table

near "ON": syntax error on revert

drop(index:).on(table) on SQLite or PostgreSQL

add .on only for MySQL, as in AddBookSlugIndex

near "," in ALTER TABLE

several field(...) calls in one update() on SQLite

one update() per field

"error in table books after drop column"

dropping a column that is part of a unique constraint on SQLite

drop the constraint first (not possible on SQLite), or rebuild the table

Migration runs again after a rename

the recorded name is the type name

keep the name stable with var name; update _fluent_migrations.name once

"duplicate column name" on re-run

a non-transactional migration died between two statements

wrap in database.transaction; idempotent steps

"Private migrations not allowed" at boot

a private or local migration type

make it internal

Fatal error: Received EOF on stdin

the confirmation prompt without a terminal

pass --yes in scripts and CI

no such table: books at the first request

migrations never ran (no --auto-migrate, no migrate step)

run the command in the deploy

Two replicas fail with "table already exists"

--auto-migrate on every replica

run migrate once, as a deploy step

Compiler warning on .sql(raw:)

renamed in fluent-kit 1.57.0

use .sql(unsafeRaw:)

Compiler error "cannot find type 'UUID' in scope"

InternalImportsByDefault is on and the file does not import Foundation

import Foundation where UUID or Date appears

Compiler error "static method 'sql' is not available due to missing import"

MemberImportVisibility is on

import FluentSQL and import SQLKit in the file that uses .sql(...)

Production data wiped

--auto-revert or autoRevert() outside tests

never enable either in production

Version notes

  • Vapor 3 and Fluent 3 had database-specific migration protocols (SQLiteMigration, PostgreSQLMigration, and a model could conform with an empty extension Galaxy: SQLiteMigration {}), whose static prepare(on:) and revert(on:) took a connection and returned Future<Void>. They were registered with a MigrationConfig in configure (migrations.add(model: Galaxy.self, database: .sqlite) or migrations.add(migration:database:)), as the archived Vapor 3 documentation shows. Fluent 4 (Vapor 4, 2020) made migrations instances, added app.migrations.add, the batch log and --auto-migrate.

  • AsyncMigration arrived with the async/await APIs of fluent-kit 1.16.0 (October 2021) and is the form used here; Migration with futures is legacy.

  • .sql(raw:) was renamed .sql(unsafeRaw:); the old name is deprecated in fluent-kit 1.57.0.

  • Vapor 5: Fluent 5 is not released and Vapor 5 is at 5.0.0-beta.4, with no published Fluent release for it, so this page describes migrations as Vapor 4 / Fluent 4.13.0 behaviour.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco): chapters 5 to 7 (Vapor 4) create tables with migrations whose methods return EventLoopFuture<Void>, and chapter 26 (Vapor 3 era text in the early-access edition) changes a deployed schema. The concepts, one migration per change, ordering by dependency and the migrate command, are unchanged. The signatures are now async throws, and the Vapor 3 forms (Migration with a typealias Database, Database.create, addProperties, MigrationConfig) do not exist in Vapor 4. Where the book calls autoMigrate().wait() in configure, write try await app.autoMigrate().

  • Yeung (Packt) uses Fluent 3 migrations (migrations.add(model:database:) in MigrationConfig); there is no MigrationConfig in Vapor 4.

  • Patel (Packt) configures the Vapor 2 MongoDB provider, which has no migrations. For MongoDB in Vapor 4 see the MongoDB page.