Home

Vapor Migrations

12 July 2026

Migrations in Vapor

In the Vapor Documentation, Migrations are referred to as a kind of version control for your server. I personally think of migrations like a git commit history. You make your initial commit, then make changes along the way. You can also revert if you need to. Where this mental model breaks down is the fact that there are no “branches” in this commit history. So your git tree only has one big trunk. But much like you can only make a particular commit once, a Vapor Migration will only ever run once. So with each update to your model, you’ll need to run a new Migration each time in order for the changes to take effect on the server.

Before we look at a Vapor Migration, lets refresh our mind with the Status Model we talked about in the last post.

Status Model Refresher
final class Status: Model, Content, @unchecked Sendable {
	static let schema = "status"

	@ID(key: .id)
	var id: UUID?

	@Field(key: "timeStamp")
	var timeStamp: Date

	@Field(key: "body")
	var body: String

	@Field(key: "likes")
	var likes: Int

	// A Status belongs to a UserProfile
	@Parent(key: "userProfileID")
	var userProfile: UserProfile

	init() { }

	init(id: UUID? = nil, timeStamp: Date, body: String, likes: Int, userProfileID: UserProfile.IDValue) {
		self.id = id
		self.timeStamp = timeStamp
		self.body = body
		self.likes = likes
		// Status is a child of UserProfile
		self.$userProfile.id = userProfileID
	}
}
The Migration

While the Model defines what our Status object looks like, it’s the Migration that actually creates the table in our database. After the table is first created, a new Migration must be run to make changes over time. To quote from the docs;

“These could be changes to the database schema like adding or removing a table or collection, field, or constraint. They could also modify the database content, like creating new model instances, updating field values, or doing cleanup.”

Important The need to match column names; if you don’t match the schema and field names between your Model and Migration, Fluent won’t be able to decode the row into your Model and you’ll get a somewhat cryptic decode error. So take extra care in making sure the names you’ve typed between the Model and Migration match exactly.

Below is a complete Migration written for our Status Model;

import Fluent

struct CreateStatus: AsyncMigration {
	func prepare(on database: any Database) async throws {
		try await database.schema("status")
			.id()
			.field("timeStamp", .datetime, .required)
			.field("body", .string, .required)
			.field("likes", .int64, .required)
			.field("userProfileID", .uuid, .required)
			.create()
	}

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

Let’s break down that Migration line by line. We’ll start with import Fluent. As defined by the Vapor Documentation, “Fluent is an ORM framework for Swift.” Great, what is an ORM? ORM stands for Object Relational Mapper and it does the heavy lifting converting the data between our PostgreSQL relational database that Vapor uses, and the Object Orientated Language of Swift.

I am very much a novice when it comes to relational databases, so I wont go too deep into this. As I understand it the PostgreSQL database groups values as tuples which are then stored as tables on the server. Fluent converts those tables into Swift objects.

Then we have the AsyncMigration itself with the prepare(on:) and revert(on:) methods respectively. To quote from the documentation:

The prepare method is where you make changes to the supplied Database. These could be changes to the database schema like adding or removing a table or collection, field, or constraint. They could also modify the database content, like creating new model instances, updating field values, or doing cleanup.

The revert method is where you undo these changes, if possible. Being able to undo migrations can make prototyping and testing easier. They also give you a backup plan if a deploy to production doesn’t go as planned.

That’s what the documentation says, but lets look at the prepare(on:) method more personally. After the function signature we have try await database.schema(“status”). This identifies the table name we are adding or modifying. At this moment we are identifying the table to build in our database called “status”. Notice the table/schema name matches the one used in the status model. As I stated earlier, it is very important these names match! I’m using a raw String for this blog, but in my production code I create an enum to avoid typo’s. Use whatever method works for you.

Next we have .id(). This adds the default column id to the table schema. The column corresponds to the @ID(key: .id) var id: UUID? property wrapper in the Status model and will be the primary key for every Status row. When you create a new Status, a new UUID for that property is generated by Fluent before saving. The .id() in the migration makes sure the table has a column ready to store it.

Moving down the list into the .field properties. Each .field in the Migration creates the columns matching each @Field(key:) from your Model. Much like the schema name, the field name in the Migration must match the key name from the Model. This is illustrated below;

	/// From the Status Model
	@Field(key: "body")
	var body: String

	/// In the CreateStatus Migration the word "body"
	/// matches that of the "body" in the Status Model
	.field("body", .string, .required)

Then we declare the type, in this case a .string. With the .required method we declare the “body” field as a required item for a Status. If we had latitude and longitude information attached to a Status, but the user wanted to keep their location hidden, the lat/long properties would be declared as OptionalField in the Model and the .required portion of the migration would be omitted. Like so;

	/// Example of Optional Fields in the Vapor Model
	@OptionalField(key: "lat")
	var lat: Double?
	
	@OptionalField(key: "long")
	var long: Double?

	/// Example of declaring the OptionalField in the Fluent Migration
	/// notice the lack of .required in the declaration
	.field("lat", .double)
	.field("long", .double)

After declaring all our .field columns we call .create() on the database schema. This actually creates the table on our database with all required columns.

Revert

The revert(on) method is pretty straightforward, especially at this initial table creation stage. It will delete the table matching the schema name “status”.

Later, as we add fields and make changes to our model, we’ll write more revert methods to undo those specific changes if they didn’t go as planned. But for now this concludes the setup of a table with a new model and initial Migration.

Register the Migration

Have you ever written a function in your code then wondered why it’s not producing the output you expected, only to realize it’s because you never called the function? Yeah, me neither…but I hear it happens. The same thing can happen with Fluent Migrations. In order for the Migration to actually run you need to do two steps. First “Register” the Migration in your app’s configure file.

Here’s an example of what that looks like in my sample project for this blog;

/// configures your application
func configure(_ app: Application) async throws {
	// uncomment to serve files from /Public folder
	// app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))

	app.databases.use(DatabaseConfigurationFactory.postgres(configuration: .init(
		hostname: Environment.get("DATABASE_HOST") ?? "localhost",
		port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
		username: Environment.get("DATABASE_USERNAME") ?? "vapor_username",
		password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password",
		database: Environment.get("DATABASE_NAME") ?? "vapor_database",
		tls: .prefer(try .init(configuration: .clientDefault)))
	), as: .psql)
  
	/// This is where the Migrations are Registered in your Vapor App
	app.migrations.add(CreateUserProfile())
	app.migrations.add(CreateStatus())

	// register routes
	try routes(app)
}

Most of that code is boilerplate built when you first created your Vapor project. You’ll find this code in the configure file in your project. I added a comment where the migrations are Registered in that file. If you don’t Register the migrations, they won’t be able to run. Not that someone like you, or I, would ever forgot something so important.

One more thing…

Notice I’ve been using the word “register” pretty strongly. That’s because actually executing a migration takes one final step after you’ve deployed the project to your server.

After the deployment is complete, from your terminal you’ll execute the command swift run YourProjectName migrate.

Executing that command you’ll see the migration(s) run, and if everything went well you’ll see a message saying “No new migrations”. Congratulations you have now built, registered, deployed and run migrations on your server!

The only thing constant is change.

As with many things in life, change happens. At some point in the life of your database you’ll want to make a change. To do that with a table you’ll not only update your Model, but you’ll also need to run a new Migration. We talked about attaching location information to a Status, this could be used if a person was “checking in” at a coffee shop or restaurant for example. Let’s update our Status Model to hold the Optional lat/long information to support this feature.

First we update our Status Model to hold these two new properties. I placed inline comments where I changed the model from its original version.

final class Status: Model, Content, @unchecked Sendable {
	static let schema = "status"
	
	@ID(key: .id)
	var id: UUID?
	
	@Field(key: "timeStamp")
	var timeStamp: Date
	
	@Field(key: "body")
	var body: String
	
	@Field(key: "likes")
	var likes: Int
	
	// A Status belongs to a UserProfile
	@Parent(key: "userProfileID")
	var userProfile: UserProfile
	
	@OptionalField(key: "latitude") // Added optional latitude
	var latitude: Double?
	
	@OptionalField(key: "longitude") // Added optional longitude
	var longitude: Double?
	
	init() { }
	
	init(
		id: UUID? = nil,
		timeStamp: Date,
		body: String,
		likes: Int,
		userProfileID: UserProfile.IDValue,
		latitude: Double?, // Don't forget to update your initializer.
		longitude: Double?
	) {
		self.id = id
		self.timeStamp = timeStamp
		self.body = body
		self.likes = likes
		// Status is a child of UserProfile
		self.$userProfile.id = userProfileID
		self.latitude = latitude
		self.longitude = longitude
		}
	}

After the model is updated, we need to create a new Migration to update the status table already in our database.

struct AddLocationDataToStatus: AsyncMigration {
	func prepare(on database: any Database) async throws {
		try await database.schema("status")
			.field("latitude", .double)
			.field("longitude", .double)
			.update()
	}

	func revert(on database: any Database) async throws {
		try await database.schema("status")
			.deleteField("latitude")
			.deleteField("longitude")
			.update()
	}
}
Don't forget the endpoints!

One thing I’m skipping over is updating the endpoints in the route collection to actually work with the new lat/long information on a Status. Since the new lat/long values are optional, and declared so with default values of nil, all your existing endpoints will still work. But you will need to either update those existing endpoints, or create new ones to be able to work with the location data.

What’s nice is if you’re only updating the methods in your endpoints, you don’t have to run a new migration. As you push changes to your server the updated methods will update automatically. I’m not going to go back to the endpoints in this post however, I believe with the knowledge you have on working with endpoints you can update your methods to create and read the new location data. Maybe even apply a filter, if you’re keeping up with the blog. 😊 Remember those DTO’s as well!

Migration Naming

I named the new migration AddLocationDataToStatus. You can name the migration whatever you like of course. Remember it will only run once and it’s like a git commit / version control on your database. So naming it with something that pertains to the changes you made could be helpful in the future.

Also notice the use of .update() in both the prepare and revert methods. Since the table already exists in the database, we aren’t creating a new table, just updating the fields of an existing one.

Do you remember the final three steps?

  1. Register the new Migration
  2. Push to Server
  3. Run the new Migration

Step 1: To Register the migration, remember we have to add a line to our configure file

// Migration to support location data
app.migrations.add(AddLocationDataToStatus())

Step 2: Pushing to your server isn’t in the scope of this blog post. But there are good resources in the official documentation. That links directly to the Heroku instructions because that is the service I use, but you can take your pick. You can even deploy locally to a Raspberry Pi for a fun home server project!

Step 3: After deployment is complete on your server of choice, be sure and call swift run YourProjectName migrate

I want to touch one more time on that revert method inside a Migration. If you deploy to the server, run the migrations and something breaks or you don’t like the result, you can call swift run YourProjectName migrate --revert to undo the last migration. I’ll say it again, a migration will only run once on your server. After that you have to create a new one to update the table schema on your server.

Wrapping up

We talked about adding new fields to our database table, but we haven’t covered changing an existing field. It can be fairly trivial depending on the situation, changing an Int to a Double for example. But in a follow up post I will cover how to update our timeStamp field to Vapor’s built in @Timestamp property wrapper. I’ll also cover how to work with enums in Vapor because creating an enum Migration was pretty confusing to me the first time I did it.

Thank you for reading!

I hope this post helps you understand Migrations using Vapor and Fluent. In writing this blog I am not only trying to help others learn about Vapor, but I am also learning myself. I actively test the server side code using a Raspberry Pi that I have set up as a test server. However, if you see errors or major omissions, please don't hesitate to reach out to me using the links below.

Supporting Links

Connect with Dan: Bluesky Instagram

Download The Midst on the App Store

Find me on The Midst: dan

Vapor Documentation

Created in Swift with Ignite