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.
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).
Source: Vapor docs: Fluent migration
Points that matter in practice:
-
Migrationis the original protocol:prepare(on:)andrevert(on:)returnEventLoopFuture<Void>. It still exists, andAsyncMigrationis a layer over it, soapp.migrations.addtakes both. Write new migrations asAsyncMigration; the future-based form is legacy and is only needed when reading older code. -
MigrationisSendable. A migration is a value that Fluent keeps in a list and calls from a task it chooses. Make it astructwithSendablestored properties (a cheap, immutable value such as aBoolor aString), never a class holding mutable state. -
databaseis the connection the migrator gives you. Use it, notreq.dborapp.db: a migration has no request, and using another handle can run the statement on a different connection than the one inside a transaction. -
revertshould undoprepareand nothing else. If a step cannot be undone (dropped data), say so by throwing fromrevertrather 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 (theAddBookSluglisting below does, and the captured output shows the shorter name); -
a migration type declared
privateor inside a function has an unstable default name. The migrator refuses it: for a name that containsunknown context atit logs a critical message and stops the process with the fatal error "Private migrations not allowed" (the checks are inMigrator.preventUnstableNamesin fluent-kit 1.57.0). Declare migrationsinternalorpublic.
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 optionalDatabaseID. Withoutto:the migration belongs to the default database;app.migrations.add(m, to: .analytics)targets a second database registered withapp.databases.use(_:as:). Themigrateoutput prints the target next to each name (on <default>above). -
Order is the dependency order. A migration that creates a foreign key to
authorshas to run after the one that createsauthors;addorder is run order, andrevertwalks it backwards. Fluent does not infer dependencies. -
Registration is cheap and must be the same every time.
configureregisters the whole history on every start; the command decides what to run. Conditional registration, such as theapp.environment != .productioncheck around the seed above, is allowed, with one caution: a migration that was registered when it ran must stay registered, orrevertcannot find it. Registering it only outside production is safe because production never records it. -
app.fluent.migrationLogLevel(aLogger.Level, default.info) controls the[Migrator] Starting prepareandFinished preparelines. 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()
}
}
Source: Vapor docs: Fluent schema
Things to read out of it:
-
db.enum("book_status")...create()returns theDatabaseSchema.DataTypethat the laterfield("status", status, .required)uses. On PostgreSQL this creates a native enum type; on SQLite the column is stored asTEXT(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_taggets the composite.unique(on: "book_id", "tag_id"), which prevents attaching the same tag to a book twice. -
revertdeletes 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-migrateis 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
revertfor 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 |
|---|---|
|
Creates the table. |
|
Changes an existing table: adds fields and constraints, updates and deletes fields, deletes constraints. |
|
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 |
|---|---|
|
|
|
fixed-width integers; |
|
|
|
|
|
|
|
|
|
|
|
|
|
nested dictionaries and |
|
nested arrays |
|
a native enum, obtained from |
Source: Vapor docs: Fluent schema, field
Field constraints
-
.requiredmakes the columnNOT NULL. Omit it for an optional property:biois@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)));autosays 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 ofreferences. It is the formupdate()accepts when you add a key to a table that already exists. -
ForeignKeyActionhas.noAction(the default),.restrict,.cascade,.setNulland.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 anddeletedfilters never see the cascaded rows. Where the Transactions, Model Middleware & Soft Delete page says "soft delete", do not combine it with.cascadeand 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()
}
}
Source: Vapor docs: Fluent schema, enum
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.
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 |
works |
one |
Delete a column |
works on SQLite 3.35+ unless the column is part of a |
works |
drop the constraint first, which SQLite cannot do |
|
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 |
Add an enum case |
no-op (no native enum) |
|
keep the Swift enum and the database in step |
Transactional DDL |
yes (the whole |
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 anupdate()failed with the same message; -
.unique(on: "slug", name: "uq_books_slug")in anupdate()failed with the same message (captured below); -
dropping
isbnfailed with "error in table books after drop column: expressions prohibited in PRIMARY KEY and UNIQUE constraints", becauseisbnis unique; -
three
fieldcalls in oneupdate()failed withnear ",".
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()
}
}
Source: Vapor docs: Fluent advanced, SQL
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: "-")
}
Source: Vapor docs: Fluent migration
-
The backfill uses
SlugRow, a model that exists only inside this file, with only the columns the migration needs. The application’sBookmodel changes over time (it may later dropslugor rename a field); a migration that queriedBookwould then break or change meaning. A frozen,privatemodel, with its columns spelled as strings, cannot. Note the@unchecked Sendableon 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 withrange, because a long single transaction holds locks. -
The backfill and the
ALTER TABLEare 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 indatabase.transactionwhen 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-3fb8fb6banda-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()
}
}
Source: Vapor docs: Fluent advanced, SQL
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.
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()
}
}
Source: Vapor docs: Fluent migration
-
Per environment. The seed is registered only when
app.environment != .production, so a production database never receives demo rows, and the capturedmigrate --env productionrun listed four migrations and no seed.Environmentalso has.developmentand.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
CreateBookshelfandAddBookSlug, which is why the later backfill filled the seeded books'slug(the captured run). Registered afterAddBookSlug, the seed would have to setslugitself. -
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.
-
revertfor a seed deletes by something that identifies exactly the rows the seed made (here the exact list of seeded ISBNs and the author name), neverBook.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 {}
Source: Vapor docs: Fluent transaction
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 NULLviolation. Define a key once with an extension ofFieldKeyif 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
SlugRowdoes), or SQLKit, andschema("name")with strings for DDL. -
The model’s
@Enum,@Timestamp(format:)and@OptionalFieldchoices decide whether a column isrequired; an optional Swift property over arequiredcolumn fails onnil. -
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 }
Source: Vapor docs: Testing
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
-
AsyncMigrationinheritsSendable. A struct with onlySendablestored properties is enough; a migration with a closure property needs a@Sendableclosure. -
prepareandrevertrun in whatever task the migrator uses; do not touch main-actor state. Everything they need comes throughdatabase. -
Migration-local Fluent models are mutable classes: declare them
@unchecked Sendable, as theBookmodel does, and keep instances inside the method that created them (see Fluent Models for why Fluent models are notSendableby construction). -
try await app.autoMigrate()isasync; call it inconfigureafter alladdcalls and before the server starts. The future-returningautoMigrate()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." |
|
declare constraints in |
|
|
add |
|
several |
one |
"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 |
"duplicate column name" on re-run |
a non-transactional migration died between two statements |
wrap in |
"Private migrations not allowed" at boot |
a |
make it |
|
the confirmation prompt without a terminal |
pass |
|
migrations never ran (no |
run the command in the deploy |
Two replicas fail with "table already exists" |
|
run |
Compiler warning on |
renamed in fluent-kit 1.57.0 |
use |
Compiler error "cannot find type 'UUID' in scope" |
|
|
Compiler error "static method 'sql' is not available due to missing import" |
|
|
Production data wiped |
|
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 emptyextension Galaxy: SQLiteMigration {}), whose staticprepare(on:)andrevert(on:)took a connection and returnedFuture<Void>. They were registered with aMigrationConfiginconfigure(migrations.add(model: Galaxy.self, database: .sqlite)ormigrations.add(migration:database:)), as the archived Vapor 3 documentation shows. Fluent 4 (Vapor 4, 2020) made migrations instances, addedapp.migrations.add, the batch log and--auto-migrate. -
AsyncMigrationarrived with theasync/awaitAPIs of fluent-kit 1.16.0 (October 2021) and is the form used here;Migrationwith 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 themigratecommand, are unchanged. The signatures are nowasync throws, and the Vapor 3 forms (Migrationwith atypealias Database,Database.create,addProperties,MigrationConfig) do not exist in Vapor 4. Where the book callsautoMigrate().wait()inconfigure, writetry await app.autoMigrate(). -
Yeung (Packt) uses Fluent 3 migrations (
migrations.add(model:database:)inMigrationConfig); there is noMigrationConfigin Vapor 4. -
Patel (Packt) configures the Vapor 2 MongoDB provider, which has no migrations. For MongoDB in Vapor 4 see the MongoDB page.
References
-
vapor/fluent-kit 1.57.0: Migration (Migrator, MigrationLog, Migrations)
-
vapor/fluent 4.13.0: MigrateCommand.swift and FluentProvider.swift
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)