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:
| 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 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
- 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), 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"
}
}
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"
}
}
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.
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):
| 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.
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.
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
- Versioned workflow - manage the same attributes as reviewable migration files
- Automatic migrations for YugabyteDB - the basics of the
ysql://driver - CI/CD setup - deploy declarative schemas to production
Have questions? Feedback? Find our team on our Discord server or schedule a demo.