Skip to main content

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:

DecisionAttributeWhat it controls
ColocationcolocationWhether a table shares one tablet with its neighbours, or gets its own
Pre-splittingsplit_into, split_at_valuesHow many tablets a table starts with, and where the boundaries are
Shardingsharding = HASH | RANGEWhether 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

  1. Docker
  2. Atlas installed on your machine:

To download and install the latest release of the Atlas CLI, simply run the following in your terminal:

curl -sSf https://atlasgo.sh | sh
note

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.

  1. An Atlas Pro account (run atlas login to 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
If the connection is refused right after CREATE DATABASE

YugabyteDB 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:

atlas.hcl
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:

atlas.hcl
env "local" {
url = getenv("DATABASE_URL")
dev = "docker://ysql/latest"
schema {
src = "file://schema.hcl"
}
migration {
dir = "file://migrations"
}
}
Match colocation between dev and target

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.hcl
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 database

The 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:

AttributeRequiresMeaning
split_intohash primary keyStart with N tablets, boundaries chosen by hash range
split_at_valuesrange primary keyStart 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:

schema.hcl
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, +∞).

Combinations the database rejects

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 for ORDER BY ... LIMIT to 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.

schema.hcl
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
}
}
}
Defaults

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:

migrations/20260811192931_initial_schema.sql
-- 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):

AttributeRound-trips?Why
colocation = falseyesStored in pg_class.reloptions
split_at_valuesyesRange boundaries are recoverable from the tablet list
split_intonoSee below
sharding on a primary keyyes
sharding on a multi-column indexnoReported 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.

Drift checks

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:

schema.hcl
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:

migrations/20260811193015_add_shipments.sql
-- 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.

What the migration directory does and does not capture

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

Have questions? Feedback? Find our team on our Discord server or schedule a demo.