Migrations, migrations, migrations. Every non-trivial application is inevitably going to need to do a migration at some point. Plans change. You or your boss decides to add a new feature or change something. And now your beautifully well-designed schema needs to change. But how do you do that when you have hundreds, thousands, millions of rows already in your database? You can't just change the schema. I mean, Convex won't allow you to do that. So, how do we do this? Also, why on earth can't you just drop a column in your schema? I mean, you could do this with Postgress. So, why can't we do this on Convex as well? Well, if these questions are you, don't worry, you're not alone because I had these when I first started using Convex as well. There are very good reasons why it works like this, but that doesn't stop these being excellent questions and hence why migrations is a topic that I get asked about more than any other right now. [music] And the answers to these questions get to the very heart of what makes Convex, well, Convex and the reason why I personally love it. So, stick around, grab yourself a lovely cup of tea, settle in, drop me a like and sub, and we're going to do a deep dive into migrations. [snorts] All right, to understand migrations, we've got to take a little step back first and understand a little bit what actually makes up a Convex app. So your app or well the technical name is a deployment is comprised of two main things really is your functions and your data. So your functions which are you know your queries, mutations and actions [music] they operate on the data. Now every convex table has a schema that defines the shape of your data. And by the way did you know that you can choose not to explicitly define a schema? Yeah. Yeah. So, if you open up the comics dashboard and [music] just create a new project, then you can go to the data tab and then just create a table and then insert some data of any shape into it. And obviously at this point, we haven't actually defined a schema in code. We haven't uploaded any functions or anything yet. Now, if we open this uh menu here with the three dots, we can actually view the schema. So although we haven't actually defined a schema yet, ComX is still maintaining an implicit behindthe-scene schema which it automatically updates whenever the data changes. As you can see, if I change this age value here from a number to a string, we can see that it becomes a union of float 64 and string. Now this implicit schema mode of convex is very handy for experimenting with things but I wouldn't actually recommend using it like this in production. Instead what we probably want to usually do is we define explicitly define a schema in our code. Then comx is going to flip into its much more restrictive mode where it will not allow us to insert or update rows in our table that does not conform to our explicitly defined schema. So this is the key thing to realize about convex at this point. [music] Even though your data is presented as tables in the dashboard here, you shouldn't think about it like that. It's not actually how it's stored behind behind the scenes. Instead, it's actually just basically a series of JSON documents that's in a big list. And the explicit schema just protects what can and can't get written into that list. So if you open up a uh locally hosted version of convex which is basically just an SQLite file by default you can see in this JSON value uh column here in this documents table it's just a JSON object [snorts] or I can hear you saying okay Mike what has this got to do with migrations and I am getting there just hang on one second. So now that we have our data protected by our explicit schema, convex is going to guarantee that our documents inside of our database are going to match this shape. So now the final part to consider is our functions that operate on that data. So we have this contract, I suppose you could call it, between our function and our schema, right? So convex is very nice in that it generates a TypeScript interface for you. So basically, we're going to get yelled at by TypeScript if we try and push something into our table that's in the wrong shape. And I mean, we can disable this TypeScript type checking if we like. And we can see that our comx functions are going to get pushed up. But if we do try and insert something into the database, we're still going to get an error. Comx is going to catch us at the runtime. So the point I'm trying to show is that your functions and your schema, you should think of it as one unit. They're bound by this contract between yeah, your functions and your schema. And they both get deployed to convex atomically as one unit. And this is great because thinking about application like this means we can have a really nice feature uh which is zero downtime updates. So basically once everything has been uploaded [music] and so long as convex is happy with the schema then we can just simply switch all traffic from the old version to our new version and our users going to be none the wiser. Now, it does get a little bit more tricky when you have to consider longunning actions, but I mean that's details behind the scene that Comics has to worry about, not you. So, again, just to reiterate, so long as this V1 schema can safely transition to this V2 schema, then everything is gravy. But if the schema can't safely transition, then we can't update our functions either. And basically, this whole update gets rejected and we remain on v2. So, the question is, what do we mean by safely transition the schema? Well, this gets to the heart of what a migration is. [music] So, let's take a little closer look at this part here. Let's keep it simple and just say we have a single table, a single users table that has only three rows in it. So, in V1, our schema looks like this. We have a name, which is a string, and an age, which is a number. Now, let's say that we want to do a new version where we're going to add, I don't know, like a email field to uh users. Well, if we try and do this in the code with the comx dev server running, comx won't let us do this because it's going to say that all documents must have an email field. And so if the schema was to be applied right now, then it wouldn't be the case because our existing three documents don't have an email field. So the question is how how do we solve this then? Well, there are a few ways that I can think of. Firstly, if comics had a mechanism where you could run through all of the rows before the schema was applied and then update them to add the email address, well then the schema then would be [music] valid and so then the update could be applied. this would work but this function that we're going to run could take quite a while to you know to iterate through all the rows in the table particularly if like for example for each user we need to look up to some external service and come back to get that email address and time is not something we have when we're actually aiming for zero downtime deployments. So if we want these versions to increment atomically, you know, we go from version one to version two to version three, etc., then we can't have a moment in time where some rows are valid state and some are not. Basically, the only way to do this is to bring the whole system down, you know, stop all traffic to your application, do this function that runs through all the rows and then bring the system back up again. And this is what, believe it or not, what people used to do back in the day. I mean, I probably still do a lot. Um, I've seen it a lot in in games. Uh, but it's not ideal ideal. Obviously, it's annoying for users and yeah, trust me, it's it's not a great experience. So, another way we could tackle this is if we could provide a default value here, then it wouldn't matter if these documents didn't have an email address or not because we would just use the default value instead. And in Postgress land or SQL land, this sort of thing is possible, you know, because you can do something a bit like this where you say here the email is text and it's not [music] null and the default is whatever. But right now, ComX doesn't have any sort of uh default concept like this. And I don't think it ever will to be honest because if you think about it, what does it even mean to have a default email address? And the same goes for just about any value. I mean what does it mean to have a default value there? Now I think the only real solution is the third one which is firstly to make this email field optional. In other words, it can either be a string or it can just simply not exist yet. So now our schema is valid because the shape matches our existing data. You know the string it could be a string or not there. The trade-off with this though is that our functions, our logic is going to have to add some additional code to handle the possibility of a user's email address either being there or not there. Now, we can fix this if we have an operation that's going to go through all of our rows in our table and add an email address after we do our schema update. For example, we can have it again look up the email addresses from some external service. And this is again very similar to that option two we had before, except instead of doing it before the schema update, we're doing it after the schema update. So now, so as long as our functions that insert new users into the table also provide an email address somehow, maybe they look up that external service again or the user provides it or something, then we can be sure now that all of the rows in our table have an email address. And so then the final step now is that we can go back and change our schema to tidy it up and make this no longer optional because we now have an email address for every user. [music] And this final change should update just fine. Convex should allow it [music] because again all the rows have an email address. And then that's it. We now have an email field added and it's guaranteed to have a value for every single row which is awesome. So, what [snorts] we just did, this three-step process is how we do migrations on Convex. These three steps allow us to have zero downtime updates and maintain integrity of our data. So, in summary, the three steps are one, we update the schema to be in its loose form first and then add logic to our functions to handle both potential states our data can be in. Two, we run our migration script to update our old data to the new form. And three, we finally go back and update our schema again to tighten it and remove the additional logic from our functions. And if voila, we have now updated our application to support the new feature with no downtime whatsoever. You're happy, your boss is happy, everybody's happy. But Mike, this seems like a lot of work to make one simple schema change. And yeah, I kind of agree. Schema changes are one of the more expensive things you can do during software development. And it doesn't really matter what database you're using, which is why I think you must think extra hard when [music] you make any change that is hard to reverse. And this is particularly important in the age of AI. So, I've actually started telling people that if they're going to read any code that the AI farts out, then make it sure that it's the schema code. So, if your AI changes schema.ts, make sure to give that a good read because not only do your serverside functions usually depend very tightly on your schema, but that this your server functions then flow out the types of that flow out to your front end. So if you change your schema then it's going to have a large number of ripple on changes like ripple on effects throughout your application. Yeah. And again it doesn't really matter what database you're using here whether it's convex or postgress or [ __ ] or whatever. If you want to do zero downtime updates this is the way you're going to have to do it. You will have to do this this type loosening then the data migration and then the third part where you tighten up your types is optional but I would very strongly suggest that you do it otherwise your schema is just going to become more and more optional over time which is really hard for humans to reason about and AIs to reason about because your your data can be in a vast number of states and a vast number of states mean [music] a great deal of complexity extra logic that you wouldn't need to have to handle if your schema was stricter. Okay. Now, with that all being said, there are situations where you can change a schema without having to worry about doing a migration. I won't cover them all here, but here are some of the main ones. Firstly, if you if there is no data in your table, then you can simply change the schema as many times as you like. It's not going to affect anything obviously. And this is important because when you're developing locally, you can just blast your tables, blast all the data in your tables and then change a schema and not have to worry about doing migrations. Next, as we have seen, you can actually make the schema more permissive without a migration, such as making a field optional, or you can add a um new member to a union if you're if you're using union, because this means that you're going to be matching the old form of your data and your potential new form of data. And actually this is how you would actually change the type of an existing field. You would first make that field a union and then you would migrate your data and then you just remove that union leaving you with your new type. And the final way you don't need to do migrations is if you have an optional field and you haven't populated any of those fields like field values yet like like our emails from before. You can just safely delete that optional field because it's not going to break any data in your database. Actually, let's just talk about dropping fields a bit more because this is a complaint I've heard from some folks. Why can't you simply drop a column on convex like you can on Postgress? Well, this kind of goes back to what we were talking about before about convex [music] being a document database and storing its data as JSON objects, [music] not as polymer tupils like in Postgress. I haven't actually checked with the team on this one, but I suspect dropping a field would require scanning through all the documents and updating each to remove that field that you're trying to drop. Alternatively, I can imagine that on each document read, you know, we could do some sort of object shaping that could exclude that field based upon the current schema, but this sounds really complex and I can imagine would slow down reuse. So, yeah, maybe not for now. All right, let's look at some quick tips and some tools that can help you doing migrations on Convex. Firstly, as with most complex things on Convex these days, the hard work has been done for you in the form of a convex migrations component. You can simply define a function that handles a single row of data. Then the component will take care of iterating through that entire table in batches for you and keep track of where you're up to. And in typical convex component fashion, there's a million different settings and options there. So, you should be able to accommodate just about any workflow you have. Just point your agent at it and let it rip. Speaking about agents, we also have an agent skill that is basically a condensed form of what we discussed in this video. You can install it globally with npx skills or you can have it shipped directly to your project along with other AI goodies if you run npx convex AI files install. I personally find that with the component and the skill and just how good agents are these days, you can very often just ask the agent to perform all the three steps of the migration process itself. Just let it rip, you know, just let it open the PRs, run the tests, deploy, run the data migration, and then do the schema update, the third schema update. And this just works for my experience. It's much less effort than having to sit there and babysit the AI uh the whole time. So in software development, if I an update goes wrong, it's common practice to roll back to a previous known working version. With migrations though, this is a bit more complicated depending where you are in the migration process. So if you have yet to run the data migration part, then you should be good because you can just revert the code and schema back to what it was before. However, if you have run or in the middle of running your data migration, then your best bet probably is just to roll forward. That is do another update that migrates from your broken state back to the known good working state. [music] This will be a bit of a pain to handle, but thanks to the no downtime atomic updates nature of Convex, your users should be none the wiser. Okay. Well, I think we've covered just about everything I want to here, but if there's something that I've missed or you have another question, please do leave me a comment down below and I'll do my best to answer. And I hope this video was useful for you. And if it was, I would really love a like and sub. And if you like this kind of technical explainer, then you're in luck because I have another one right here where I go deep on how convex actually works underneath the hood. You're going to love it. But that's about it for me for today. Until next time, thanks for [music] watching. Cheerio.
Every non-trivial application eventually needs a database migration. Plans change, someone decides to add a new feature or rework an existing one, and now your well-designed schema needs to change. But how do you do that when you already have hundreds, thousands, or millions of rows in your database? You can't just change the schema. Convex won't allow it.
There's a related question that trips people up just as often. Why can't you drop a column, the way you can in Postgres?
If those questions sound familiar, you're not alone. I had them when I first started using Convex. There are good reasons it works this way. That doesn't make them any less annoying the first time you hit them, and database migration is the topic I get asked about more than anything else right now. The answers get at what makes Convex Convex, which is also why I like it. Let's get into it.
How Convex deployments work
To understand migrations, it helps to back up and look at what makes up a Convex app. Your app, technically a deployment, is made of two things: your functions and your data. Your functions, your queries, mutations, and actions, operate on the data.
Implicit versus explicit schemas
Every Convex table has a schema that defines the shape of your data. You can choose not to define one explicitly. Open the Convex dashboard, create a project, go to the data tab, make a table, and insert data of any shape. Convex still maintains an implicit schema behind the scenes, updating it automatically whenever the data changes. Change a value from a number to a string, and the inferred type becomes a union of float64 and string.
This implicit mode is handy for experimenting, but I wouldn't recommend it in production. Instead, you typically define an explicit schema in code, in schema.ts. Once you do, Convex flips into a more restrictive mode: it won't let you insert or update rows that don't conform to that schema.
Data storage and JSON documents
Even though your data shows up as tables in the dashboard, don't think about it that way, because that's not how it's stored behind the scenes. It's a series of JSON documents in a list, and the explicit schema restricts what can and can't be written into that list. If you self-host Convex locally with the default SQLite backend, you'll see a value column of type JSON in the documents table, and it's just a JSON object sitting there.
That storage model is the reason migrations work the way they do. With an explicit schema, Convex guarantees that every document in the database matches that shape. Functions and schema form a contract. Convex generates a TypeScript interface for you, so TypeScript complains if you try to push something of the wrong shape. You can turn off TypeScript checking, but Convex still catches invalid writes at runtime. Think of your functions and your schema as a single unit, because that's exactly how Convex deploys them, atomically and together.
Atomic deployments and zero downtime
That atomicity is what makes zero-downtime updates possible. Once everything is uploaded and Convex is happy with the schema, it switches all traffic from the old version to the new version without users noticing. It gets trickier with long-running actions, but that's a detail Convex handles for you.
So long as the V1 schema can safely transition to the V2 schema, everything is fine. If it can't safely transition, the function update gets rejected too, because functions and schema move together.
This raises the real question. What does it mean for a schema to "safely transition"? That's what a database migration really is.
The three-step migration process
Take a simple example. Say you have a users table with three rows, and in V1 the schema has name: string and age: number. Now you want to add an email field. If you try this in code with the Convex dev server running, Convex won't let you, because it would require every existing document to already have an email field, and yours don't:
1// convex/schema.ts — adding a required field to a table that already has rows2users:defineTable({3 name: v.string(),4 age: v.number(),5 email: v.string(),6}),7
Convex refuses to apply it and prints a schema validation error: the existing documents in users are missing the required email field. So how do you solve it?
One option is to run a function that iterates through all rows before the new schema is applied and backfills the email address on each one. That would make the schema valid before you flip it over. But that function could take a long time, especially if you have to hit an external service per user to find an email address. Time isn't something you have if you want a zero-downtime deployment. If versions increment atomically, you can't have a moment where some rows are valid and some aren't. The only way to force that would be to bring the whole system down, stop traffic, run the migration, and bring it back up. People used to do that, and some still do, especially in games, but it's not ideal.
The other instinct is to reach for a default value, the way you would in SQL. Convex doesn't have a default concept, and I don't think it's going to get one. What would a default email address even mean? The same question applies to most fields you'd actually want to add.
The practical solution is a three-step process:
Loosen the schema. Make the new field optional, so email can be a string or not exist yet. Now the schema matches your existing data. The trade-off is that your functions have to handle both possibilities, present or absent, until step three.
Migrate the data. Run a migration script that updates old rows to the new shape, for example, backfilling email addresses by calling out to an external service. At the same time, make sure new inserts always provide an email address, so you're not digging the hole deeper while you're filling it in.
Tighten the schema. Once every row has an email, update the schema to make email required again and remove the extra handling from your functions. Convex allows this final change because every document already conforms to the tightened schema.
Three-step schema migration
That loosen-migrate-tighten sequence is what lets you keep zero downtime while still guaranteeing data integrity.
Yes, that's extra work for what looks like a simple schema change. Schema changes are one of the more expensive things in software development, regardless of which database you're using, so it's worth thinking hard before making one that's difficult to reverse. That's especially true now that AI tools are often the ones proposing the change. If an AI edits schema.ts, review that diff carefully. Your server-side functions depend tightly on the schema, and the generated types flow all the way to the frontend, so a careless change can ripple across the whole app.
When migrations are not required
There are situations where you can change the schema without going through any of this:
If there's no data in the table yet, change the schema freely.
When you're developing locally, just clear your tables and change the schema without a migration.
You can make a schema strictly more permissive without a migration, like making a field optional or adding a member to a union, because that new shape matches both the old and new forms of the data.
If you've got an optional field you've never populated, delete it safely, since there's no data depending on it.
Changing the type of an existing field is the exception. It follows the same loosen-migrate-tighten pattern: turn the field into a union first, migrate the data, then remove the union once everything conforms.
Why dropping columns is complex
Why can't you just drop a column the way you would in Postgres? Convex is a document database storing data as JSON objects, not columnar tuples. Dropping a field would mean either scanning every document and physically removing that field, or shaping each document on every read to exclude fields the current schema no longer declares. Both are expensive and add complexity that a columnar database with fixed-width rows doesn't have to deal with. Given that trade-off, the loosen-migrate-tighten pattern ends up being the more predictable option.
Tools for database migrations on Convex
Convex has a migrations component that handles the batch-processing side of this for you. You define a function that operates on a single row, and the component iterates through the table in batches, tracking progress as it goes. It has enough options to fit a range of different workflows, so you're not writing the pagination and progress-tracking logic yourself every time.
There's also an agent skill that condenses the pattern described above into something an AI agent can follow. Install it globally with npx skills, or ship it to your project with npx convex ai files install. Combine the component, the skill, and a capable modern coding agent, and you can often just ask the agent to run all three migration steps: open the PRs, run the tests, deploy, run the data migration, and do the final schema tightening. In my experience it works, and it's noticeably less effort than babysitting each step by hand.
Rollback and roll forward
Rollback with migrations is more complicated than a normal deploy. If you haven't yet run the data migration, you can revert the code and schema back to the previous working state without much trouble. But if you're partway through a data migration when something breaks, it's usually best to roll forward rather than back, applying another update that migrates from the broken state to a known-good one. That's more painful to execute, but because Convex's deployments are atomic and zero-downtime, users shouldn't notice either way.
If you want the deeper architectural picture behind all of this, that's what "How Convex Works" covers.
All gas, no breakages
Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.