Managing YugabyteDB Distributed Schemas (Versioned)
With YugabyteDB's distributed schema model, every table is split into tablets that are spread across the nodes of the cluster. The schema decides how that split happens out of three options:
| Decision | Attribute | What it controls |
|---|---|---|
| Colocation | colocation | Whether a table shares one tablet with its neighbours, or gets its own |
| Pre-splitting | split_into, split_at_values | How many tablets a table starts with, and where the boundaries are |
| Sharding | sharding = HASH | RANGE | Whether a key or index column is hash-distributed or kept in sorted order |
The decision is fixed when the object is created. YugabyteDB has no ALTER TABLE ... SET COLOCATION
and no way to re-declare split points in place, so getting these wrong means recreating the table later.
By managing your YugabyteDB schema as code, you review the distribution layout in a pull request
instead of discovering the decision in production.
This guide covers the versioned workflow. For the declarative approach, see the declarative distribution guide.
YugabyteDB support is available only to Atlas Pro users. To use this feature, run:
atlas login
Prerequisites
- Docker
- Atlas installed on your machine:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
YugabyteDB support is only available on v1.3.1 or later. Check your version by running atlas version.
Update to the latest version by reinstalling.
- An Atlas Pro account (run
atlas loginto authenticate)
Start a Colocated Database
Begin by running a local single-node cluster:
docker run --rm -d --name atlas-yb \
-p 5433:5433 \
yugabytedb/yugabyte:latest \
bin/yugabyted start --background=false
YugabyteDB takes a few seconds to begin running. Wait until you see that it is ready:
docker exec atlas-yb bin/yugabyted status | grep 'YSQL Status'
| YSQL Status: Ready |
Now create the target database with colocation enabled:
docker exec atlas-yb bash -lc \
'bin/ysqlsh -h "$(hostname -i)" -U yugabyte -d yugabyte -c "CREATE DATABASE shop WITH COLOCATION = true"'
Confirm the flag took effect:
docker exec atlas-yb bash -lc \
'bin/ysqlsh -h "$(hostname -i)" -U yugabyte -d shop -tAc "select current_database(), yb_is_database_colocated()"'
shop|t
CREATE DATABASEYugabyteDB propagates catalog changes asynchronously across nodes, so a brand-new database can be briefly invisible to a new connection. Retry the command once.
Configuring Atlas
Set up your Atlas configuration file with an environment that points to your database URL
(url), desired schema file (schema.src), migrations directory (migration.dir), and
dev database (dev).
The dev database is an ephemeral database where Atlas normalizes your desired state before diffing it against
your database when planning a migration. If colocation is used in your YugabyteDB schema, then the dev
database must be colocated, as well. A dedicated docker "ysql" block creates one:
docker "ysql" "dev" {
image = "yugabytedb/yugabyte:latest"
database = "dev"
colocation = true
}
env "local" {
url = getenv("DATABASE_URL")
dev = docker.ysql.dev.url
schema {
src = "file://schema.hcl"
}
migration {
dir = "file://migrations"
}
}
If your schema uses only pre-splitting and index sharding, the plain shorthand works and no docker block
is needed:
env "local" {
url = getenv("DATABASE_URL")
dev = "docker://ysql/latest"
schema {
src = "file://schema.hcl"
}
migration {
dir = "file://migrations"
}
}
If the dev database is not colocated, colocation = false is already its default, so Atlas normalizes
the attribute away and loses your opt-out. The result is a spurious DROP TABLE / CREATE TABLE on every
apply. Always pair a colocated target with a colocated dev database.
Point the target at the shop database you just created:
export DATABASE_URL="ysql://yugabyte@localhost:5433/shop?search_path=public&sslmode=disable"
Define Desired Schema
The schema file is the target that Atlas diffs against. Let's model a small shop that uses all three distribution levers: colocation, pre-splitting, and index sharding.
Colocated tables
In a colocated database every table is colocated by default, sharing a single tablet with its neighbors. That is the right choice for small, low-throughput tables, because a join between two colocated tables is served from one node with no network hop.
Colocation may not be the right choice for two kinds of tables:
- Tables with real write throughput. A single tablet is a single Raft leader, so it is a bottleneck that cannot be scaled out.
- Lookup tables used in many joins. These are read from every node. Sharding them by primary key lets each node read the range it needs, instead of funnelling every lookup through the one node that owns the colocation tablet.
Opt a table out with colocation = false:
schema "public" {
}
// Small, low-throughput lookup table: colocated (the database default).
table "currencies" {
schema = schema.public
column "code" {
null = false
type = character_varying(3)
}
column "name" {
null = false
type = character_varying
}
primary_key {
columns = [column.code]
}
}
// Lookup table used in many joins: opt out and shard by primary key.
table "products" {
schema = schema.public
colocation = false
column "id" {
null = false
type = bigint
}
column "sku" {
null = false
type = character_varying
}
column "price" {
null = false
type = numeric(10,2)
}
primary_key {
columns = [column.id]
}
}
Note what is not written: currencies says nothing about colocation. Atlas treats an undeclared colocation
as unspecified, so the table simply follows the database default. Only the opt-out is explicit.
colocation = false needs a colocated databaseThe attribute describes an opt-out from a database-level default. Against a non-colocated database there is no default to opt out of, making the attribute meaningless. Atlas does not read it back in this case, so the diff never converges. Use it only with a colocated target.
Pre-split tablets
A new table starts with a small number of tablets. As it grows, YugabyteDB splits them automatically, but splitting is a background operation that competes with your transactions. If you already know a table will be large, pre-splitting it at creation avoids that work entirely.
There are two forms, and which one you can use depends on how the primary key is sharded:
| Attribute | Requires | Meaning |
|---|---|---|
split_into | hash primary key | Start with N tablets, boundaries chosen by hash range |
split_at_values | range primary key | Start with tablets divided at the listed key values |
Add split_into = 6 to products, and an orders table that is pre-split at known id boundaries:
table "products" {
schema = schema.public
colocation = false
split_into = 6
// ... columns as above
primary_key {
columns = [column.id] // hash-sharded: required by split_into
}
}
// High-throughput table: range primary key, pre-split at known boundaries.
table "orders" {
schema = schema.public
colocation = false
split_at_values = ["1000000", "2000000", "3000000"]
column "id" {
null = false
type = bigint
}
column "customer_id" {
null = false
type = bigint
}
column "placed_at" {
null = false
type = timestamptz
}
primary_key {
on {
column = column.id
sharding = RANGE // range-sharded: required by split_at_values
}
}
}
Three split points produce four tablets: (-∞, 1000000), [1000000, 2000000), [2000000, 3000000),
and [3000000, +∞).
Pre-splitting a colocated table, or using split_at_values on a hash primary key, fails at apply time:
Error: create "t1" table: pq: cannot create colocated table with split option (42P16)
Error: create "t2" table: pq: SPLIT AT option is not yet supported for hash partitioned tables (XX000)
Generating the migration first means you see the offending DDL in review, before it reaches a real database.
Index sharding
Indexes are also distributed objects, and the same hash-or-range choice applies to each column. Declare it
with a sharding attribute inside an on block:
sharding = HASH– the column is hash-distributed. Spreads load evenly, and serves equality lookups (WHERE customer_id = ?) from a single tablet. It cannot serve range scans.sharding = RANGE– the column is kept in sorted order. Required for range predicates (WHERE placed_at > ?) and forORDER BY ... LIMITto read one tablet instead of all of them.
The common shape for a time-series index is hash on the entity, range on the timestamp. Reads for one customer land on one tablet, and within that tablet the newest rows are contiguous.
table "orders" {
// ... as above
index "orders_customer_id_placed_at_idx" {
on {
column = column.customer_id
sharding = HASH
}
on {
column = column.placed_at
sharding = RANGE
desc = true
}
}
index "orders_placed_at_idx" {
on {
column = column.placed_at
sharding = RANGE
}
}
}
A bare columns = [column.a, column.b] list is not "unsharded". In YSQL, it means hash on the leading
column and range on the rest.
The exception is a colocated table. With a single shared tablet, there is nothing to hash across, so its
primary key is created as RANGE.
Generating the Initial Migration
Generate the first migration file:
atlas migrate diff initial_schema --env local
Atlas creates the migration directory with the generated SQL and a checksum file
(atlas.sum):
migrations/
├── 20260811192931_initial_schema.sql
└── atlas.sum
Every distribution attribute is compiled into the DDL, where it can be reviewed:
-- Create "currencies" table
CREATE TABLE "public"."currencies" (
"code" character varying(3) NOT NULL,
"name" character varying NOT NULL,
PRIMARY KEY ("code" ASC)
);
-- Create "orders" table
CREATE TABLE "public"."orders" (
"id" bigint NOT NULL,
"customer_id" bigint NOT NULL,
"placed_at" timestamptz NOT NULL,
PRIMARY KEY ("id" ASC)
) WITH (colocation = false) SPLIT AT VALUES ((1000000), (2000000), (3000000));
-- Create index "orders_customer_id_placed_at_idx" to table: "orders"
CREATE INDEX "orders_customer_id_placed_at_idx" ON "public"."orders" ("customer_id", "placed_at" DESC);
-- Create index "orders_placed_at_idx" to table: "orders"
CREATE INDEX "orders_placed_at_idx" ON "public"."orders" ("placed_at" ASC);
-- Create "products" table
CREATE TABLE "public"."products" (
"id" bigint NOT NULL,
"sku" character varying NOT NULL,
"price" numeric(10,2) NOT NULL,
PRIMARY KEY ("id")
) WITH (colocation = false) SPLIT INTO 6 TABLETS;
Applying the Migration
Apply the directory to the target database:
atlas migrate apply --env local
Migrating to version 20260811192931 (1 migrations in total):
-- migrating version 20260811192931
-> CREATE TABLE "public"."currencies" (
"code" character varying(3) NOT NULL,
"name" character varying NOT NULL,
PRIMARY KEY ("code" ASC)
);
-> CREATE TABLE "public"."orders" (
"id" bigint NOT NULL,
"customer_id" bigint NOT NULL,
"placed_at" timestamptz NOT NULL,
PRIMARY KEY ("id" ASC)
) WITH (colocation = false) SPLIT AT VALUES ((1000000), (2000000), (3000000));
-> CREATE INDEX "orders_customer_id_placed_at_idx" ON "public"."orders" ("customer_id", "placed_at" DESC);
-> CREATE INDEX "orders_placed_at_idx" ON "public"."orders" ("placed_at" ASC);
-> CREATE TABLE "public"."products" (
"id" bigint NOT NULL,
"sku" character varying NOT NULL,
"price" numeric(10,2) NOT NULL,
PRIMARY KEY ("id")
) WITH (colocation = false) SPLIT INTO 6 TABLETS;
-- ok (1.146309443s)
-------------------------
-- 1.275329293s
-- 1 migration
-- 5 sql statements
Confirm the recorded state:
atlas migrate status --env local
Migration Status: OK
-- Current Version: 20260811192931
-- Next Version: Already at latest version
-- Executed Files: 1
-- Pending Files: 0
Verifying the Distribution Layout
The migration says what was requested. To confirm what the cluster built, query yb_table_properties:
docker exec atlas-yb bash -lc 'bin/ysqlsh -h "$(hostname -i)" -U yugabyte -d shop -c "
SELECT c.relname, c.reloptions,
(yb_table_properties(c.oid)).num_tablets,
(yb_table_properties(c.oid)).num_hash_key_columns,
(yb_table_properties(c.oid)).is_colocated
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = '\''public'\'' AND c.relkind IN ('\''r'\'','\''i'\'')
ORDER BY c.relname;"'
relname | reloptions | num_tablets | num_hash_key_columns | is_colocated
----------------------------------+--------------------+-------------+----------------------+--------------
currencies | | 1 | 0 | t
currencies_pkey | | | |
orders | {colocation=false} | 4 | 0 | f
orders_customer_id_placed_at_idx | | 1 | 1 | f
orders_pkey | | | |
orders_placed_at_idx | | 1 | 0 | f
products | {colocation=false} | 6 | 1 | f
products_pkey | | | |
Using Schema Inspection
Running atlas schema inspect shows how Atlas reads the layout back:
atlas schema inspect --env local
table "currencies" {
schema = schema.public
// ...
primary_key {
on {
column = column.code
sharding = RANGE
}
}
}
table "orders" {
schema = schema.public
colocation = false
split_at_values = ["1000000", "2000000", "3000000"]
// ...
index "orders_customer_id_placed_at_idx" {
on {
column = column.customer_id
}
on {
desc = true
column = column.placed_at
}
}
index "orders_placed_at_idx" {
on {
column = column.placed_at
sharding = RANGE
}
}
}
table "products" {
schema = schema.public
colocation = false
// ...
}
Two differences from the file you wrote are expected, and neither is drift (the second apply above already
returned Schema is synced):
| Attribute | Round-trips? | Why |
|---|---|---|
colocation = false | yes | Stored in pg_class.reloptions |
split_at_values | yes | Range boundaries are recoverable from the tablet list |
split_into | no | See below |
sharding on a primary key | yes | |
sharding on a multi-column index | no | Reported as a plain column list |
split_into is deliberately not reported. It states an initial tablet count, and YugabyteDB changes the
live count on its own during scaling and automatic splitting. Reporting the current count would make every
automatic split look like a schema change.
Because inspected output is normalized this way, comparing atlas schema inspect > current.hcl against
your checked-in file with a text diff will report differences that are not drift. Use
atlas schema apply --dry-run, which compares the parsed states, instead.
Making Incremental Changes
When a new table is needed, update the schema file and let Atlas compute the diff.
Let's add a shipments table. It has high throughput, so we opt out of colocation, pre-split, and index
hash-on-entity/range-on-time:
table "shipments" {
schema = schema.public
colocation = false
split_into = 12
column "id" {
null = false
type = bigint
}
column "order_id" {
null = false
type = bigint
}
column "shipped_at" {
null = false
type = timestamptz
}
primary_key {
columns = [column.id]
}
index "shipments_order_id_shipped_at_idx" {
on {
column = column.order_id
sharding = HASH
}
on {
column = column.shipped_at
sharding = RANGE
desc = true
}
}
}
Generate the incremental migration:
atlas migrate diff add_shipments --env local
Atlas emits only what changed in the new migration file:
-- Create "shipments" table
CREATE TABLE "public"."shipments" (
"id" bigint NOT NULL,
"order_id" bigint NOT NULL,
"shipped_at" timestamptz NOT NULL,
PRIMARY KEY ("id")
) WITH (colocation = false) SPLIT INTO 12 TABLETS;
-- Create index "shipments_order_id_shipped_at_idx" to table: "shipments"
CREATE INDEX "shipments_order_id_shipped_at_idx" ON "public"."shipments" ("order_id", "shipped_at" DESC);
Lint the directory before applying on a distributed database. migrate lint is where a DROP TABLE
caused by a changed distribution attribute surfaces.
atlas migrate lint --env local --latest 2
-- analyzing version 20260811193015
-- no diagnostics found
-- ok (114.42µs)
-------------------------
-- 2 versions ok
-- 7 schema changes
Apply the migration:
atlas migrate apply --env local
-- migrating version 20260811193015
-> CREATE TABLE "public"."shipments" (
"id" bigint NOT NULL,
"order_id" bigint NOT NULL,
"shipped_at" timestamptz NOT NULL,
PRIMARY KEY ("id")
) WITH (colocation = false) SPLIT INTO 12 TABLETS;
-> CREATE INDEX "shipments_order_id_shipped_at_idx" ON "public"."shipments" ("order_id", "shipped_at" DESC);
-- ok (678.509262ms)
-------------------------
-- 1 migration
-- 2 sql statements
The directory now holds both files, and their sum is the current schema:
migrations/
├── 20260811192931_initial_schema.sql
├── 20260811193015_add_shipments.sql
└── atlas.sum
Confirming the 12 tablets landed:
docker exec atlas-yb bash -lc 'bin/ysqlsh -h "$(hostname -i)" -U yugabyte -d shop -c "
SELECT c.relname, (yb_table_properties(c.oid)).num_tablets,
(yb_table_properties(c.oid)).num_hash_key_columns
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = '\''public'\'' AND c.relname LIKE '\''shipments%'\'' ORDER BY 1;"'
relname | num_tablets | num_hash_key_columns
-----------------------------------+-------------+----------------------
shipments | 12 | 1
shipments_order_id_shipped_at_idx | 1 | 1
shipments_pkey | |
Changing Colocation
Since colocation is fixed at creation, changing it is not an ALTER. Suppose products turned out to be
small after all, and you remove its colocation = false
Running atlas migrate diff produces a recreate:
-- Drop "products" table
DROP TABLE "public"."products";
-- Create "products" table
CREATE TABLE "public"."products" (
"id" bigint NOT NULL,
"sku" character varying NOT NULL,
"price" numeric(10,2) NOT NULL,
PRIMARY KEY ("id" ASC)
);
atlas migrate lint flags it before it ships:
-- destructive changes detected:
-- L2: Dropping table "products" https://atlasgo.io/lint/analyzers#DS102
-- suggested fix:
-> Add a pre-migration check to ensure table "products" is empty before dropping it
On a table with rows, this needs a planned move. See pre-migration checks and the destructive change policy.
Wiring migrate lint into CI means this shows up as a failing check on the pull request rather than as
data loss on deploy. See CI/CD setup.
If the table is referenced by a foreign key, Atlas refuses to generate the migration at all:
Error: postgres: cannot change the colocation of table "products", as it is referenced by foreign key
"product_tags_product_id_fkey" of table "product_tags". Colocation is fixed on table creation, hence
changing it requires recreating the table and all tables referencing it
The guard prevents a migration the database would reject halfway through. Resolving it takes two migrations: drop the referencing table in one, flip the parent and recreate the child in the next.
split_into states an initial tablet count. YugabyteDB changes the live count on its own during scaling
and automatic splitting, and Atlas does not report the live count back from inspection. Otherwise, every
automatic split would look like schema drift. Therefore, the migration file records the count the table was
created with, which is the reviewable decision. The current count is an operational property, queried
with yb_table_properties as above.
Cleaning Up
Clean up your Docker-spun cluster:
docker rm -f atlas-yb
Next Steps
- Declarative workflow - describe the desired layout and let Atlas plan the diff
- Automatic migrations for YugabyteDB - the basics of the
ysql://driver - CI/CD setup - deploy versioned migrations to production
Have questions? Feedback? Find our team on our Discord server or schedule a demo.