Skip to main content
DB
Blog ·

Several writers, one row: attribute groups

Two pipelines that update different parts of the same entity should never overwrite each other. Attribute groups give each writer its own columns and its own version order on one row.

By The NYXDB team5 min read

A balance row has a holding, written by the service that tracks balances, and an activity count, maintained from the transfers. A device row has a position from one feed and a battery level from another. The row is one entity, but its columns have different owners, and in most databases the second writer overwrites the first, or you split the entity across tables and join it back together on every read.

Attribute groups let several writers each own part of one row. A group is a named set of columns of a keyed table, and each group is its own write and conflict domain. A write that targets one group changes only that group’s columns. It never overwrites another group, and a stale write to one group does not hold back a newer write to another. The row is still one typed row: a read returns all of its columns together.

Declaring who owns what

Groups belong to the keyed table modes, upsert and versioned. They are declared inside the table definition, after the columns, each with an optional ordering of its own:

one row, two writers, one table
CREATE TABLE balances (
chain_id UINT16 NOT NULL,
address STRING NOT NULL,
token STRING NOT NULL,
amount UINT256 NULL,
updated_at DATETIME64(3, 'UTC') NULL,
balance_index UINT128 NULL,
balance_version UINT64 NULL,
transfer_count UINT64 NULL,
last_transfer_at DATETIME64(3, 'UTC') NULL,
activity_index UINT128 NULL,
PRIMARY KEY (chain_id, address, token),
ATTRIBUTE GROUP holding (amount, updated_at, balance_index, balance_version)
VALID BY (balance_index)
REVISION BY (balance_version),
ATTRIBUTE GROUP activity (transfer_count, last_transfer_at, activity_index)
VALID BY (activity_index)
)
MODE VERSIONED
SHARD BY (address) SHARDS AUTO
ORDER BY (chain_id, address, token);

Here holding is written by the service that tracks balances and is ordered by its balance index; activity is maintained by a transformation over the transfers and ordered by its activity index. Groups are disjoint, so a column belongs to at most one of them. The primary-key, shard and partition columns identify the row and sit outside the groups. A group’s ordering applies only to that group’s writes, and a group without one is ordered by commit. A table holds up to 256 groups.

A write that touches one group

The write statements take a trailing group name. The statement then names the key columns and that group’s columns, and changes nothing else on the row:

two writes to the holding group, out of order
UPSERT INTO balances (
chain_id, address, token, amount, updated_at, balance_index, balance_version
)
VALUES (1, '0x9c2e4f1a', 'USDC', 2750000000, '2026-10-01 12:05:00.000', 1002, 1)
ATTRIBUTE GROUP holding;
UPSERT INTO balances (
chain_id, address, token, amount, updated_at, balance_index, balance_version
)
VALUES (1, '0x9c2e4f1a', 'USDC', 2500000000, '2026-10-01 12:00:00.000', 1001, 1)
ATTRIBUTE GROUP holding;

The second statement arrives after the first but carries an earlier balance_index. Because the holding group is ordered by that column, the later-arriving write is retained as an earlier version and the current amount stays 2750000000. The activity group is unaffected by either write. A delete, by contrast, removes the whole key, every group included.

This is what makes the last-writer-wins problem disappear. Each producer carries its own order in its own columns, the engine settles each group on its own terms, and no pipeline can clobber another’s columns by arriving late or arriving twice.

Rows that fill in one group at a time

The first group write on a new key stores a row that holds the key and that group. Columns that no write has supplied read as null, so a balance row can exist with only its activity group, before any holding write has arrived. The transformation that counts transfers does not wait for the balance service, and the balance service does not wait for it.

A transformation as one of the writers

A transformation can own one group of its target, naming the group after the target table in its definition. The rest of the row stays writable by other statements and other transformations. In the table above, activity is kept by a grouped transformation over the transfers, while the balance service keeps writing the holding group, and neither sees the other’s columns change under it.

Lineage is tracked per group, so a transformation can read a table and write one group of that same table without receiving its own writes back. The post on derived tables covers how that publication works.

Adding a group later

A table grows a new group with an alter statement that declares the group’s new columns inline. Transformations that write the other groups of the table keep running across the change and across restarts, so a new owner joins the row without the existing owners stopping.

The attribute groups concept page has the member rules and the alter example, the write statements show the trailing group clause on each of them, and versioned tables explains the ordering a group declares for itself.

Read next

Run every statement in this post yourself

Install a node, create the two tables the docs build, and read them fresh, versioned and live from one engine.