Skip to main content

Managing YugabyteDB Distributed Schemas (Declarative)

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 declarative workflow. For versioned migrations, see the versioned 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), 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"
}
}

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"
}
}
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.

Applying the Schema

Run atlas schema apply to diff the desired state against the live database and execute the plan:

atlas schema apply --env local

Atlas prints the DDL it will run. Every distribution attribute shows up in the generated SQL:

Planning migration statements (5 in total):

-- create "currencies" table:
-> CREATE TABLE "currencies" (
"code" character varying(3) NOT NULL,
"name" character varying NOT NULL,
PRIMARY KEY ("code" ASC)
);
-- create "orders" table:
-> CREATE TABLE "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 "orders" ("customer_id", "placed_at" DESC);
-- create index "orders_placed_at_idx" to table: "orders":
-> CREATE INDEX "orders_placed_at_idx" ON "orders" ("placed_at" ASC);
-- create "products" table:
-> CREATE TABLE "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;

-------------------------------------------

? Approve or abort the plan:
▸ Approve and apply
Abort

Run it again to confirm the schema converges:

Schema is synced, no changes to be made

Verifying the Distribution Layout

The plan says what Atlas asked for. To confirm what the cluster actually 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.

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:

atlas schema apply --env local --dry-run

Atlas plans a recreate, and the linter flags it:

Planning migration statements (2 in total):

-- drop "products" table:
-> DROP TABLE "products";
-- create "products" table:
-> CREATE TABLE "products" (
"id" bigint NOT NULL,
"sku" character varying NOT NULL,
"price" numeric(10,2) NOT NULL,
PRIMARY KEY ("id" ASC)
);

-------------------------------------------

Analyzing planned statements (2 in total):

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

If the table is referenced by a foreign key, Atlas refuses to plan the change 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 is protecting you from a plan the database would reject halfway through. Resolving it takes two applies: drop the referencing table in one change, flip the parent and recreate the child in the next.

Decide colocation up front

This is the practical argument for reviewing distribution attributes in a pull request. Colocation is cheap to choose before the first apply and expensive to change once the table holds data.

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.