Skip to content

Basics

Migrations

Migrations are versioned CFCs that carry your schema forward and back. Each one subclasses wheels.migrator.Migration, exposes an up() method that applies a change and a down() that undoes it, and lives in app/migrator/migrations/ with a timestamped filename so the runner can order them. The same migrations run in every environment — development, test, CI, production — which is how a fresh clone of your repo reaches the current schema by running one command.

You’ll learn:

  • Running migrations — with the wheels CLI, and without it (browser GUI or programmatic)
  • The wheels migrate subcommands — the day-to-day four, plus reconciliation for when something’s off
  • How to generate or hand-write a migration file and what the timestamped filename means
  • The column builder methods on createTable — string, integer, datetime, references, timestamps
  • How to add and remove indexes and foreign keys without dropping a table
  • How to add, rename, and remove columns on an existing table
  • When a migration can’t be reversed and how to signal that honestly

Four subcommands cover the day-to-day workflow. Each one connects to your app’s datasource and reads the migration history out of wheels_migrator_versions, so the app has to be running for them to work — without a server, every wheels migrate subcommand refuses with a pointer at wheels start instead of guessing at a database. See Installing Wheels for the server setup.

Terminal window
wheels migrate info
  • wheels migrate latest — apply every pending migration in order. The most common command; you’ll run it after pulling someone else’s changes.
  • wheels migrate up — apply exactly one pending migration. Useful when you want to watch the effect of a single file.
  • wheels migrate down — roll back the most recently applied migration. Runs the down() method of the newest row in wheels_migrator_versions.
  • wheels migrate info — show which migrations have run and which are still pending. Read-only; safe any time. Orphan versions (rows in the tracking table with no matching file in app/migrator/migrations/) are flagged with [?] <version> <name> (applied <timestamp>) when the enriched wheels_migrator_versions.name / .applied_at columns are populated, or [?] <version> ********** NO FILE ********** (Rails-style) for legacy rows that pre-date the schema enrichment.

The runner wraps each migration in a transaction so a failing up() or down() rolls back cleanly on databases that support transactional DDL — but that support varies, so read the caution below before relying on it. One more caveat for scripts and CI: a failed migration is loud in the command output but the CLI currently still exits 0, so don’t gate a pipeline on the exit code alone (#3081).

A migration step that runs SQL against another datasource, through queryExecute(..., {datasource: "other"}) or a model with dataSource("other"), can’t run inside the per-step transaction on Adobe ColdFusion. Adobe rejects the mix with Datasource names for all the database tags within the cftransaction tag must be the same, and the migrator reports that error along with the fix below.

Opt that migration out of the transaction:

component extends="wheels.migrator.Migration" {
this.useTransaction = false;
function up() {
queryExecute("CREATE TABLE audit_log (id INT)", {}, {datasource: "reporting"});
}
}

With this.useTransaction = false, the migrator runs up()/down() without a transaction and records (or removes) the version only after the step succeeds, so a failed step is never marked as applied. Nothing is rolled back if the step fails part-way, so keep such migrations small and idempotent where you can. Every other migration keeps the default per-step transaction.

The CLI is a convenience, not a requirement — CommandBox teams and manual installs have the same capabilities:

  • Browser GUI — /wheels/migrator shows applied and pending migrations with run/rollback controls. Like all /wheels/* surfaces it’s development-only (hard allowlist since 4.0.4, #2903).

  • Programmatic — application.wheels.migrator.migrateToLatest() and .migrateTo(version) run the same runner from any server-side code path in any environment: a deploy hook, a protected admin action, a one-off script. application.wheels.migrator.getCurrentMigrationVersion() reads the current state.

  • At startup — two settings in config/settings.cfm (or an environment’s settings.cfm) migrate to the latest version when the application starts:

    • set(autoMigrateDatabase=true) is lenient: a migration that fails is logged and the application starts anyway.
    • set(migrateOnBoot=true) (4.2.0) is strict: if a migration is still pending after the attempt, the application doesn’t start. It throws Wheels.BootMigrationFailed and writes the reason to the wheels log, so a health check fails instead of the app serving an old schema.
    • A boolean WHEELS_MIGRATE_ON_BOOT environment variable overrides both: true migrates strictly, false never migrates at start. Without it, the two settings behave as above.

    Either setting is fine for a single app host. With several hosts sharing one database, every host would migrate at start at the same time: with wheels deploy, use its migrate: step, which migrates on one host only.

  • Writing the files — a migration is a plain CFC in app/migrator/migrations/ named YYYYMMDDHHMMSS_description.cfc with up()/down() methods; the generator below just saves you the boilerplate. Copy an existing file as your starting point.

The full no-CLI task map lives at Working Without the CLI.

Three additional subcommands cover the cases where the tracking table and the on-disk files have drifted — most commonly when several developers share a single dev database. Read more in Shared development databases.

  • wheels migrate doctor — single-command health report. Lists orphans, pending local migrations, and the applied count with a human-readable summary. Read-only; never mutates.
  • wheels migrate forget <version> --yes — removes a single row from wheels_migrator_versions without running down(). Use this only when a peer rolled back their migration but the tracking row stayed. Refuses if the version has a matching local file (use migrate down instead) or if the version isn’t in the table.
  • wheels migrate pretend <version> --yes — records a version as applied without running up(). Use this when a peer applied the migration via direct SQL or a different tool and you need the tracking table to reflect that. Refuses if the version is already applied or if no local file matches.

Both forget and pretend print what they would do and exit if --yes is omitted; the flag is required to mutate the tracking table.

The generator scaffolds a CFC with up() and down() stubs and drops it in app/migrator/migrations/ with a timestamp prefix.

your shell
wheels generate migration CreatePosts

That produces a file named like 20260420143000_CreatePosts.cfc — the name you typed is used verbatim. The prefix is YYYYMMDDHHMMSS — generated from your local clock at creation time — and the runner applies migrations in filename order, so the timestamp is what puts your change after everyone else’s. Never rename or reorder migration files after they’ve been committed: the history in wheels_migrator_versions keys on the timestamp prefix, and a renamed file looks like a brand-new migration to the runner.

Open the generated CFC and fill in the body. A migration for a new table typically builds the table definition with createTable(), chains column methods on the returned builder, and flushes with .create(). The down() just drops the table.

component extends="wheels.migrator.Migration" hint="Create posts table" {
function up() {
t = createTable(name="posts");
t.string(columnNames="title", allowNull=false, limit=120);
t.text(columnNames="body");
t.integer(columnNames="status", default=0);
t.datetime(columnNames="publishedAt");
t.timestamps();
t.create();
}
function down() {
dropTable(name="posts");
}
}

Three things to note:

  1. createTable("posts") returns a table-builder object. Every column method — t.string, t.integer, t.datetime — mutates that builder. Nothing hits the database until t.create().
  2. t.timestamps() creates createdAt, updatedAt, and a soft-delete column (deletedAt). Don’t also add those columns by hand — you’ll get duplicate-column errors. See Models and the ORM for how Wheels populates them automatically on save.
  3. The column-builder methods use columnNames (plural) and allowNull. Every helper also accepts columnName (singular) as an alias, but columnNames is the preferred form; the nullable flag is always allowNull — null is never accepted. A single call can create several same-typed columns at once: t.string(columnNames="firstName,lastName", limit=60).

These are the column-builder methods defined on TableDefinition. Every one takes columnNames (comma-separated string), and most accept default and allowNull. A few take type-specific extras.

MethodDB typeCommon options
t.string(columnNames=, limit=, allowNull=, default=)VARCHARlimit defaults to 255
t.char(columnNames=, limit=)CHARfixed-width string; without limit the length is 1 (10 on SQL Server). A shorter value can come back padded with trailing spaces (for example on PostgreSQL, SQL Server, Oracle and H2), so trim it before comparing
t.text(columnNames=, size=)TEXT / MEDIUMTEXT / LONGTEXTsize is MySQL-only
t.integer(columnNames=, limit=, allowNull=, default=, unsigned=)INTlimit is a display width, not a size bump — for BIGINT use t.bigInteger(). unsigned=true creates INT UNSIGNED on MySQL and is ignored elsewhere
t.bigInteger(columnNames=, unsigned=)BIGINTSigned on every database (MySQL created BIGINT UNSIGNED before Wheels 4.2). unsigned=true creates BIGINT UNSIGNED on MySQL, for a foreign key to an existing unsigned id; other databases ignore it. On SQLite the column is declared BIGINT from Wheels 4.2. Earlier versions declared INTEGER, which Wheels binds as a 32-bit integer: a value above 2,147,483,647 then fails on Adobe ColdFusion and is silently changed on Lucee (clamped to 2,147,483,647) and BoxLang (wrapped). Re-declare such a column with changeColumn(columnType="biginteger"), which rebuilds the table and keeps its data and indexes. A biginteger primary key stays INTEGER PRIMARY KEY, SQLite’s 64-bit rowid.
t.float(columnNames=)FLOAT
t.decimal(columnNames=, precision=, scale=)DECIMALuse for money
t.boolean(columnNames=, default=)BOOLEAN / BITBIT(1) on MySQL, BOOLEAN on Oracle 23ai+ (from Wheels 4.2), see below
t.date(columnNames=)DATE
t.datetime(columnNames=)DATETIME / TIMESTAMP
t.time(columnNames=)TIME
t.timestamp(columnNames=)DATETIMEsingle-column version
t.binary(columnNames=)BLOB / VARBINARY
t.uniqueidentifier(columnNames=)UNIQUEIDENTIFIER / UUID / CHAR(36)UNIQUEIDENTIFIER on SQL Server, UUID on PostgreSQL, CockroachDB and H2, CHAR(36) on MySQL, SQLite and Oracle. The default newid() becomes each database’s own generator; Oracle’s (SYS_GUID()) is unique but not a random version 4 UUID. PostgreSQL before 13 and MySQL before 8.0.13 can’t generate one in a default, so the column is created without one and a note is logged: set the value yourself, e.g. with generateUUID() (on PostgreSQL a required column is filled automatically)
t.references(columnNames=, foreignKey=, onDelete=)INT + FKcreates <name>id (or <name>_id, see below) and a FK constraint
t.primaryKey(columnNames=, type=, autoIncrement=)PK columntype defaults to integer; call multiple times for composite PKs (no comma-list — columnNames="a,b" is treated as a single literal column name)
t.timestamps()creates createdAt, updatedAt, deletedAtno arguments

Boolean columns on MySQL (from Wheels 4.2). t.boolean() creates a BIT(1) column. Earlier releases created TINYINT(1). When a MySQL DSN sets the driver option tinyInt1isBit=false, the driver reports a TINYINT(1) column as a plain integer, so the model validated it as a number and rejected true (“is not a number”). BIT(1) is reported as a boolean under every driver setting. Existing TINYINT(1) columns aren’t changed. For those, either remove tinyInt1isBit=false from the DSN, or convert them in a migration with changeColumn(table="posts", columnName="published", columnType="boolean"), which keeps the stored 1 / 0 values. A BIT(1) value reads back as 1 on Lucee and Adobe CF and as true on BoxLang; both are truthy and compare equal in CFML.

Boolean columns on Oracle (from Wheels 4.2). On Oracle 23ai and later, t.boolean() creates a native BOOLEAN column, and the model maps it to a boolean property. Earlier Wheels releases created a numeric column, which reaches the model as a number, so automatic validation rejected true. Oracle releases before 23ai get NUMBER(1). Existing numeric boolean columns aren’t changed, and Oracle can’t change the datatype of a column that holds data (ORA-01439). To convert one, add a BOOLEAN column, copy the values across, then drop the old column and rename the new one. A BOOLEAN value reads back as 1 / 0 on Lucee and Adobe CF and as true / false on BoxLang.

Integer columns on Oracle (from Wheels 4.2). On Oracle, t.integer(), t.references() and the id primary key create NUMBER(10), t.bigInteger() creates NUMBER(19), and t.boolean() before Oracle 23ai creates NUMBER(1). They bind and validate as integers, so a fractional value fails automatic validation. Earlier releases created a bare NUMBER, which accepts fractions and validates as a float; existing bare NUMBER columns aren’t changed. Without a precision argument, changeColumn() keeps a column’s current precision, so changing a default or allowNull never re-types it; Oracle also can’t narrow a column that holds data (ORA-01440). To move a populated column to NUMBER(10), add an integer column, copy the values across, then drop the old column and rename the new one:

addColumn(table = "orders", columnType = "integer", columnName = "qtyNew");
execute("UPDATE orders SET qtyNew = qty");
removeColumn(table = "orders", columnName = "qty");
renameColumn(table = "orders", columnName = "qtyNew", newColumnName = "qty");

An empty column can be changed in place with changeColumn(table = "orders", columnName = "qty", columnType = "integer", precision = 10).

t.references(columnNames="user") is the shortcut for the common case of a foreign key: it adds an integer column plus a FK constraint pointing at users.id. The legacy argument name referenceNames is still accepted; columnNames is the preferred modern form so the call site reads the same as every other column helper. Pass polymorphic=true to also create a type column for polymorphic associations. Pass foreignKey=false to skip the constraint and leave only the column — useful when the referenced table lives in another datasource.

The generated column suffix is controlled by the useUnderscoreReferenceColumns setting:

Settingt.references(columnNames="user") producesPolymorphic user produces
false (framework default)useriduserid, usertype
true (new-app template default)user_iduser_id, user_type

Apps generated by wheels new opt into true in config/settings.cfm so the column names match Wheels model belongsTo defaults (belongsTo("user") looks for user_id first). Existing apps with applied migrations keep the framework default of false so on-disk schemas continue to line up. Flip the setting in config/settings.cfm if you want to change the suffix the next migration produces — already-applied migrations are unaffected. Migration.cfc::addReference() and removeColumn(referenceName=) honor the same flag.

Indexes don’t belong in createTable() — they’re a separate concern, and you’ll often add them to existing tables as query patterns emerge. Use addIndex() and removeIndex().

component extends="wheels.migrator.Migration" hint="Index posts publishedAt" {
function up() {
addIndex(table="posts", columnNames="publishedAt");
addIndex(table="posts", columnNames="userId,status", indexName="idx_posts_user_status");
}
function down() {
removeIndex(table="posts", indexName="posts_publishedAt");
removeIndex(table="posts", indexName="idx_posts_user_status");
}
}

If you don’t pass indexName, Wheels generates one by joining the table name and first column — in this case posts_publishedAt. For multi-column indexes or when you want a predictable name, pass indexName explicitly. removeIndex always needs the name.

Pass unique=true on addIndex to create a unique constraint, which is what validatesUniquenessOf should sit on top of to race-proof the check at the database level.

t.references() inside createTable() adds a foreign key as part of the CREATE TABLE statement. When you need to add a FK to an existing table — or the foreign key doesn’t follow the <parent>Id convention — use addForeignKey().

component extends="wheels.migrator.Migration" hint="Add fk: comments.postId -> posts.id" {
function up() {
addForeignKey(
table="comments",
referenceTable="posts",
column="postId",
referenceColumn="id"
);
}
function down() {
dropForeignKey(table="comments", keyName="FK_comments_posts");
}
}

addForeignKey() takes four required arguments and generates a constraint name like FK_<table>_<referenceTable>. The down() drops it by that generated name — dropForeignKey(table=, keyName=). There’s also addReference() / dropReference() for the convention case where the column is <singular>Id and the table is <plural>: addReference(table="comments", referenceName="post") does the same thing as the long form above.

Three methods cover the common single-column changes. Each one is a method on the Migration base class — no table builder, no .create() flush.

Add a column:

component extends="wheels.migrator.Migration" hint="Add archived flag" {
function up() {
addColumn(table="posts", columnType="boolean", columnName="archived", default=false);
}
function down() {
removeColumn(table="posts", columnName="archived");
}
}

Rename a column:

component extends="wheels.migrator.Migration" hint="Rename body to content" {
function up() {
renameColumn(table="posts", columnName="body", newColumnName="content");
}
function down() {
renameColumn(table="posts", columnName="content", newColumnName="body");
}
}

Remove a column:

component extends="wheels.migrator.Migration" hint="Remove deprecated teaser" {
function up() {
removeColumn(table="posts", columnName="teaser");
}
function down() {
addColumn(table="posts", columnType="string", columnName="teaser", limit=255);
}
}

addColumn takes the same type-specific options as the table-builder methods: limit, precision, scale, default, allowNull. Pass columnType="string" or "integer" or "datetime" and so on — the same type names the t.* methods use internally.

Seeding via migrations — avoid, but if you must

Section titled “Seeding via migrations — avoid, but if you must”

Real seed data — users, roles, reference lookups — belongs in app/db/seeds.cfm where it runs idempotently via seedOnce(). See Seeding for that path. Don’t use migrations for seed data in long-lived projects: a INSERT in a migration runs exactly once, so editing the inserted row later means writing another migration to update it, and fresh clones of the repo end up with whatever the most recent migration wrote.

That said, you’ll occasionally want a migration to backfill data for a schema change — for example, populating a new column from an existing one. When you do, use inline SQL: execute() takes a single SQL string and nothing else — there is no parameters argument, so there’s no binding to reach for. For timestamps, use CURRENT_TIMESTAMP, which works across MySQL, PostgreSQL, SQL Server, H2, and SQLite. NOW() does not port: SQLite — the default database for wheels new apps — fails with no such function: NOW, and SQL Server has no native NOW() either. Other database-specific date functions (GETDATE(), SYSDATETIME()) don’t port at all.

component extends="wheels.migrator.Migration" hint="Seed admin role" {
function up() {
execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
}
function down() {
execute("DELETE FROM roles WHERE name = 'admin'");
}
}

Quote string values inline; wrap each statement in execute() individually. For anything structural — building a compound object, branching on current rows — consider writing a one-off script under app/lib/ and calling it from your deploy pipeline instead.

For plain row updates and deletes, updateRecord() and removeRecord() save you the raw SQL — and fail closed on a full-table write. Both throw Wheels.Migrator.MissingWhere when where is empty unless you pass all=true:

component extends="wheels.migrator.Migration" hint="Backfill and prune" {
function up() {
updateRecord(table="posts", where="status = 'legacy'", archived=true);
removeRecord(table="posts", where="deletedAt < CURRENT_TIMESTAMP");
}
function down() {
// An intentional full-table write still needs the explicit opt-in:
updateRecord(table="posts", all=true, archived=false);
}
}

updateRecord(table, where, ...columns) sets the extra named arguments as column values (auto-typed: booleans, numbers, dates, and strings); removeRecord(table, where) deletes the matching rows. The all=true opt-in is the tripwire that keeps an accidentally-empty where from mutating every row.

Every up() should have a matching down() so wheels migrate down can walk the history back. Most changes are trivially reversible: createTable pairs with dropTable, addColumn with removeColumn, addIndex with removeIndex.

Some aren’t. Dropping a column with data loses that data forever; a row-level transformation (UPDATE posts SET slug = LOWER(...)) can’t be undone without the original values. In those cases, write the forward change and have down() throw — loudly — so an accidental rollback fails instead of silently corrupting the schema.

component extends="wheels.migrator.Migration" hint="Slugify posts — irreversible" {
function up() {
execute("UPDATE posts SET slug = LOWER(REPLACE(title, ' ', '-'))");
}
function down() {
Throw(
type="IrreversibleMigration",
message="Cannot reverse slug transformation — original titles may differ from the generated slugs."
);
}
}

The throw is the honest signal: “you can’t walk past this point backward.” If you need to undo the change in practice, write a new forward migration.

Generated migration files are named <YYYYMMDDHHMMSS>_<NameAsTyped>.cfc — a 14-digit timestamp from your local clock, an underscore, then the name you passed to wheels generate migration, written exactly as you typed it (no snake_casing, no added suffix). The snake_case shape you may also see in app/migrator/migrations/ — 20260420143000_create_posts_table.cfc — comes from the model generator: wheels g model Post scaffolds a create_posts_table migration alongside the model. The timestamp determines the order the runner applies them. Don’t hand-edit the prefix; if you need to reorder after a merge conflict, regenerate the file and move your body into it.