Medusa generates migrations from your models, which removes an entire category of tedium and introduces a specific risk: generated SQL that you have not read is SQL you have not agreed to. A rename you did not intend reads to the generator as a drop and an add, and columns do not come back.
The commands
# Generate migrations for one module after changing its models
npx medusa db:generate my_module
# Apply all pending migrations
npx medusa db:migrate
# Create the database if it does not exist
npx medusa db:create
# Drop everything — development only
npx medusa db:rollbackdb:generate takes the module name as registered, not the directory path. Core modules manage their own migrations; you never generate for them.
Read what it produced
Change a model:
export const LoyaltyAccount = model.define("loyalty_account", {
id: model.id().primaryKey(),
customer_id: model.text().index(),
points: model.number().default(0),
tier: model.enum(["bronze", "silver", "gold"]).default("bronze"),
})Generate, then open the file. What you are checking for:
- Drops. A
DROP COLUMNyou did not intend is data loss. If you renamed something, edit the migration toRENAME COLUMNinstead. - Non-nullable additions without a default. These fail on any table with existing rows.
- New indexes on large tables. In Postgres, prefer
CREATE INDEX CONCURRENTLY, which the generator will not produce for you. - Enum changes. Removing a value fails if any row still uses it.
Treat a generated migration as a first draft. Editing it before commit is normal and expected.
Deploying them
As a discrete release step, before new code serves traffic:
{
"deploy": {
"preDeployCommand": "npx medusa db:migrate",
"startCommand": "cd .medusa/server && medusa start"
}
}Never at boot. Three instances starting together will run the same migration simultaneously, and the outcomes range from a harmless lock wait to a half-applied schema.
Zero-downtime changes
During a rolling deploy, old and new application code both run against the already-migrated schema. Any migration that breaks the old code causes errors until the rollout finishes.
The pattern is expand and contract, across two releases.
Say you are renaming points to points_balance.
Release 1 — expand.
- Add
points_balanceas a nullable column. - Deploy code that writes to both and reads from
pointsifpoints_balanceis null. - Backfill in a script or job.
Release 2 — contract.
- Deploy code that reads and writes only
points_balance. - Drop
pointsin a later migration, once you are confident.
Slower, and it is the difference between a rename and an incident. The rule of thumb: a migration should never break the currently deployed code.
What is safe and what is not
| Change | Safe during rolling deploy? |
|---|---|
| Add a nullable column | Yes |
| Add a column with a default | Yes on modern Postgres |
| Add an index concurrently | Yes |
| Add a table | Yes |
| Rename a column | No — expand and contract |
| Drop a column | No — deploy code first |
| Add NOT NULL to an existing column | No — backfill first |
| Change a column type | Usually no |
| Add an index non-concurrently | No — takes a write lock |
Rollbacks
There is no per-migration down you can lean on in production. Plan forward instead:
- Backups with point-in-time recovery, tested by an actual restore.
- Forward-only fixes. A bad migration is corrected by a new migration.
- A staging database restored from production so migrations run once against real data volume before they run against real data.
That last one catches the migration that works on 200 rows and locks a table for eleven minutes on two million.
Migrations in modules and plugins
Each module owns its migrations under src/modules/<name>/migrations/, and db:migrate runs pending ones across all modules and plugins. Plugins ship their own, which is why installing one is always followed by a migrate.
Commit generated migrations. They are source: reviewed, versioned, and identical across environments.
Practical guidance
Generate after every model change, immediately. Batching three changes into one migration makes the diff unreadable.
Name the branch after the migration. Schema changes deserve their own PR and their own review.
Test on a production-sized copy. Lock duration is a function of table size, and staging never has enough rows.
Never edit an applied migration. Environments will diverge. Write a new one.
Backfilling data
Schema changes and data changes are different operations and should be different steps. A migration that adds a column and then updates two million rows will hold a lock for minutes.
Add the column in the migration, then backfill in a script in batches:
import { ExecArgs } from "@medusajs/framework/types"
export default async function backfill({ container }: ExecArgs) {
const loyalty = container.resolve("loyalty")
const logger = container.resolve("logger")
const BATCH = 500
let offset = 0
for (;;) {
const accounts = await loyalty.listLoyaltyAccounts(
{ points_balance: null },
{ take: BATCH, skip: offset },
)
if (!accounts.length) break
await loyalty.updateLoyaltyAccounts(
accounts.map((a) => ({ id: a.id, points_balance: a.points })),
)
offset += accounts.length
logger.info(`[backfill] ${offset} accounts`)
}
}Batched, resumable, and it can run while the application serves traffic.
A pre-merge checklist for schema changes
[ ] Read the generated SQL line by line
[ ] No unintended DROP statements
[ ] New non-nullable columns have a default, or are added nullable
[ ] New indexes on large tables use CONCURRENTLY
[ ] The migration does not break the currently deployed code
[ ] Tested against a production-sized database copy
[ ] Lock duration measured, not assumed
[ ] Backfill separated into a script
[ ] Rollback plan written downThe one that catches the most real problems is testing against a production-sized copy. A migration that takes 40ms on 200 seeded rows can take eleven minutes and an exclusive lock on two million.
Multiple environments
Migrations must run in the same order everywhere, which means the same commits in the same sequence.
The practice that prevents divergence: never run a migration manually in production. If it is not in the pipeline, it did not happen — and an environment with a hand-applied migration will silently diverge until a later migration fails against a schema nobody can reconstruct.
For a hotfix that genuinely needs a schema change, still ship it through the pipeline on a hotfix branch. The five minutes saved by applying it by hand is not worth the environment drift, and the audit trail is worth having when someone asks what changed.
Keep staging's schema identical to production by restoring a production backup periodically, so migrations are always tested against a schema that actually exists.
Schema changes are where deploys go wrong quietly. We are happy to review a risky one.
Frequently asked questions
How do I create a migration in Medusa v2?
Change your module's data models, then run `npx medusa db:generate <module-name>`. Medusa diffs the models against the current schema and writes a migration file, which you should review and commit before applying with `npx medusa db:migrate`.
Can I roll back a Medusa migration?
Not reliably in production. `db:rollback` is a development convenience, and generated migrations do not always have a safe reverse. Rely on database backups with point-in-time recovery, and correct mistakes with a new forward migration.
Should I commit generated migrations?
Yes. Treat them as source code: reviewed in pull requests and versioned, so every environment applies exactly the same schema changes in the same order.
How do I avoid downtime during a Medusa migration?
Use expand and contract. Add new columns as nullable, deploy code that writes to both old and new, backfill, deploy code that uses only the new, and drop the old in a later release. Never ship a migration that breaks the currently running code.
Why does my migration fail with a not-null violation?
Because you added a non-nullable column without a default to a table that already has rows. Add it nullable, backfill the values, then add the constraint in a subsequent migration.
Do I need migrations for Medusa's core modules?
No. Core modules ship their own migrations and manage their own schemas; upgrading Medusa and running `db:migrate` applies them. You only generate migrations for modules you wrote.
Deploying Medusa to Production: Architecture, Checklist and Pitfalls
What a production Medusa deployment actually needs — process separation, Redis, migrations, health checks — and the four mistakes that cause the first outage.
Observability for Medusa: Logs, Traces and the Alerts Worth Having
Self-hosting means owning the question 'is checkout working?'. Structured logging, tracing, the four metrics that matter and alerts that do not cry wolf.
Scaling Medusa: What Breaks First and How to Fix It
Traffic does not break Medusa — the database does, then the workers, then the catalog queries. The bottlenecks in the order you will meet them.



