Table Service
The Table service is the legacy API for schema management and bulk data operations. Use it when you need operations that are not available through YQL — creating tables with fine-grained partitioning, bulk loading data, point reads by primary key, streaming full table scans, or managing secondary indexes programmatically.
For running queries use Query Service instead. The Table service does not replace the Query service; the two are complementary.
The Table service is accessed through driver.table_client:
import ydb
with ydb.Driver(endpoint="grpc://localhost:2136", database="/local") as driver:
driver.wait(timeout=5, fail_fast=True)
client = driver.table_client
Schema Management
Creating a Table
Build a TableDescription and pass it to create_table:
driver.table_client.create_table(
"/local/users",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("name", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
ydb.Column("age", ydb.OptionalType(ydb.PrimitiveType.Uint32)),
)
.with_primary_keys("id"),
)
Nullability belongs to each column, including a primary-key column.
OptionalType allows NULL. A primitive type is NOT NULL.
In YQL the same split is Uint64 (nullable) and Uint64 NOT NULL.
On a row-oriented table both forms are valid for every column. In the example
above id is NOT NULL, while name and age are nullable. A
primary-key column may be nullable too. YDB accepts NULL in a key and
recommends a non-null key: NULL equals no value, including another
NULL, so comparisons and point lookups skip those rows. See NULL in a
key column.
A column-oriented table accepts only NOT NULL primary-key columns. See
column-oriented tables.
The examples on this page create row-oriented tables.
Dropping a Table
driver.table_client.drop_table("/local/users")
Describing a Table
describe_table returns a TableSchemeEntry with column definitions, indexes,
TTL settings, and partition information:
entry = driver.table_client.describe_table("/local/users")
for column in entry.columns:
print(column.name, column.type)
for index in entry.indexes:
print(index.name, index.index_columns)
Pass DescribeTableSettings to request additional detail:
settings = ydb.DescribeTableSettings().with_include_shard_key_bounds(True)
entry = driver.table_client.describe_table("/local/users", settings)
Describing a System View
System views (the .sys schema objects) are described with describe_system_view,
which returns a SystemViewSchemeEntry with the system view id and name,
its columns, primary key, and attributes. Unlike describe_table this is a dedicated
description that omits the table-only fields that do not apply to system views:
entry = driver.table_client.describe_system_view("/local/.sys/nodes")
print(entry.sys_view_name, entry.sys_view_id)
for column in entry.columns:
print(column.name, column.type)
print(entry.primary_key)
Altering a Table
alter_table accepts keyword arguments for each type of modification. Pass
only the arguments you need — unspecified arguments are left unchanged:
Add or remove columns:
driver.table_client.alter_table(
"/local/users",
add_columns=[ydb.Column("email", ydb.OptionalType(ydb.PrimitiveType.Utf8))],
drop_columns=["age"],
)
Add or remove secondary indexes:
driver.table_client.alter_table(
"/local/users",
add_indexes=[
ydb.TableIndex("name_idx").with_index_columns("name"),
],
drop_indexes=["old_idx"],
)
Modify TTL:
driver.table_client.alter_table(
"/local/users",
set_ttl_settings=ydb.TtlSettings().with_date_type_column("expires_at"),
)
# Remove TTL
driver.table_client.alter_table(
"/local/users",
drop_ttl_settings=ydb.DropTtl(),
)
Change partitioning settings:
driver.table_client.alter_table(
"/local/events",
alter_partitioning_settings=(
ydb.PartitioningSettings()
.with_min_partitions_count(4)
.with_max_partitions_count(256)
),
)
Copying and Renaming Tables
copy_table copies a single table:
driver.table_client.copy_table(
"/local/users",
"/local/users_backup",
)
copy_tables copies multiple tables atomically:
driver.table_client.copy_tables([
("/local/users", "/local/backup/users"),
("/local/orders", "/local/backup/orders"),
])
rename_tables renames (or moves) tables atomically. Each entry is a
RenameItem. The client reads source_path,
destination_path, and replace_destination from each item:
driver.table_client.rename_tables([
ydb.RenameItem("/local/users_new", "/local/users"),
])
Pass replace_destination=True when the destination path already exists.
The server replaces that table in the same operation:
driver.table_client.rename_tables([
ydb.RenameItem(
"/local/users_new",
"/local/users",
replace_destination=True,
),
])
Secondary Indexes
Indexes are defined via TableIndex when creating or altering a table.
Global index — a synchronous secondary index. The server updates it in the same transaction as the table. Adding it to an existing table builds the index in the background: the table stays available, and queries can use the index once the build has finished:
ydb.TableIndex("name_idx").with_index_columns("name")
Global async index — updated in the background, so a write does not wait for the index. Reads through the index are eventually consistent and run in stale read-only mode. The initial build also leaves the table available:
ydb.TableIndex("name_idx").with_global_async_index().with_index_columns("name")
Covered index — stores extra columns in the index so queries can be answered without a primary-key lookup:
ydb.TableIndex("name_age_idx") \
.with_index_columns("name") \
.with_data_columns("age", "email")
Full example — create a table with a covered index from the start:
driver.table_client.create_table(
"/local/users",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("name", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
ydb.Column("email", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
ydb.Column("age", ydb.OptionalType(ydb.PrimitiveType.Uint32)),
)
.with_primary_keys("id")
.with_indexes(
ydb.TableIndex("name_idx")
.with_index_columns("name")
.with_data_columns("email"),
),
)
TTL Settings
YDB can automatically delete rows when a column value indicates the row has expired. Two modes are available.
Date-type column mode — use a Date, Datetime, or Timestamp column
whose value is the expiry time. Rows with a value in the past are deleted:
ydb.TtlSettings().with_date_type_column("expires_at")
# Optional: delay deletion by N seconds after expiry
ydb.TtlSettings().with_date_type_column("expires_at", expire_after_seconds=3600)
Value-since-unix-epoch mode — use an integer column that stores time as a Unix timestamp in the specified unit:
ydb.TtlSettings().with_value_since_unix_epoch(
"created_ts",
ydb.ColumnUnit.UNIT_SECONDS,
expire_after_seconds=86400, # delete rows 24 h after created_ts
)
ColumnUnit values: UNIT_SECONDS, UNIT_MILLISECONDS,
UNIT_MICROSECONDS, UNIT_NANOSECONDS.
Example — create a table where rows expire 7 days after creation:
driver.table_client.create_table(
"/local/events",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("data", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
ydb.Column("created_at", ydb.OptionalType(ydb.PrimitiveType.Timestamp)),
)
.with_primary_keys("id")
.with_ttl(
ydb.TtlSettings().with_date_type_column(
"created_at",
expire_after_seconds=7 * 24 * 3600,
)
),
)
Partitioning Settings
PartitioningSettings controls how the table is split into shards and how
that split evolves over time:
ydb.PartitioningSettings() \
.with_min_partitions_count(4) \
.with_max_partitions_count(256) \
.with_partition_size_mb(512) \
.with_partitioning_by_load(ydb.FeatureFlag.ENABLED) \
.with_partitioning_by_size(ydb.FeatureFlag.ENABLED)
Method |
Effect |
|---|---|
|
Auto-merge stops at n shards. A new table still starts as one shard;
pre-split it with |
|
Auto-split stops at n shards. A shard past the server force-split size (2 GiB by default) can still split above this cap. |
|
Target shard size; triggers a split when exceeded (requires by-size enabled). |
|
Auto-split when a shard grows beyond the target size. |
|
Auto-split hot shards and merge idle ones based on load. |
FeatureFlag values: ENABLED, DISABLED, UNSPECIFIED.
Pass PartitioningSettings to TableDescription.with_partitioning_settings:
driver.table_client.create_table(
"/local/events",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("data", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
)
.with_primary_keys("id")
.with_partitioning_settings(
ydb.PartitioningSettings()
.with_min_partitions_count(4)
.with_max_partitions_count(64)
.with_partitioning_by_load(ydb.FeatureFlag.ENABLED)
),
)
Pre-split at specific keys — useful when the key distribution is known upfront:
driver.table_client.create_table(
"/local/events",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("data", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
)
.with_primary_keys("id")
.with_partition_at_keys(
ydb.ExplicitPartitions(
[ydb.SplitPoint(1000), ydb.SplitPoint(2000), ydb.SplitPoint(3000)]
)
),
)
Column Families
Column families group columns for independent storage and compression settings. Useful when a table has both hot (frequently read) and cold (rarely read) columns:
driver.table_client.create_table(
"/local/users",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("name", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
ydb.Column("bio", ydb.OptionalType(ydb.PrimitiveType.Utf8), family="blobs"),
ydb.Column("avatar", ydb.OptionalType(ydb.PrimitiveType.String), family="blobs"),
)
.with_primary_keys("id")
.with_column_families(
ydb.ColumnFamily()
.with_name("blobs")
.with_compression(ydb.Compression.LZ4),
),
)
Compression values: NONE, LZ4, UNSPECIFIED.
Bulk Upsert
bulk_upsert loads rows without a transaction. It is faster than individual
INSERT/UPSERT statements for large batches. Sending the same rows again
is safe: each row is an upsert.
Build a BulkUpsertColumns descriptor that lists the columns and their types,
then pass it together with the rows:
from collections import namedtuple
User = namedtuple("User", ["id", "name", "age"])
rows = [
User(id=1, name="Alice", age=30),
User(id=2, name="Bob", age=25),
User(id=3, name="Carol", age=28),
]
column_types = (
ydb.BulkUpsertColumns()
.add_column("id", ydb.PrimitiveType.Uint64)
.add_column("name", ydb.OptionalType(ydb.PrimitiveType.Utf8))
.add_column("age", ydb.OptionalType(ydb.PrimitiveType.Uint32))
)
driver.table_client.bulk_upsert("/local/users", rows, column_types)
Each row must be an object (named tuple, dataclass, or any object) whose
attributes match the column names in BulkUpsertColumns.
Note
One bulk_upsert call is split into independent per-partition transactions.
A successful response means every row from that call is committed. A failure
can leave a subset of the rows committed; resending the same rows overwrites
them. There is no ordering across calls. Prefer UPSERT via the Query
service when the rows must commit as one transaction.
Point Reads
read_rows reads specific rows by primary key without a transaction or a
session. It is faster than a SELECT for exact key lookups and returns a
single _ResultSet.
Describe the primary key columns with BulkUpsertColumns (or
StructType) and pass a list of key structures. Missing keys are
omitted from the result rather than raising an error:
key_types = (
ydb.BulkUpsertColumns()
.add_column("id", ydb.PrimitiveType.Uint64)
)
result_set = driver.table_client.read_rows(
"/local/users",
[{"id": 1}, {"id": 2}, {"id": 999}],
key_types,
columns=("id", "name"),
)
for row in result_set.rows:
print(row.id, row.name)
Pass columns to return a subset of fields. Omit it (or pass an empty
iterable) to return every column.
Note
read_rows is not transactional. Prefer a SELECT via the Query
service when the read must participate in a transaction.
Streaming Reads
read_table
read_table streams all rows (or a key range) of a table without a
transaction. It is efficient for full-table exports and ETL pipelines:
def callee(session: ydb.table.Session):
with session.read_table("/local/users", columns=("id", "name")) as it:
for result_set in it:
for row in result_set.rows:
print(row.id, row.name)
driver.table_client.retry_operation_sync(callee)
read_table is called on a Session object. Access a session through
retry_operation_sync, which handles session lifecycle and retries.
Read a key range:
key_type = ydb.TupleType().add_element(ydb.PrimitiveType.Uint64)
key_range = ydb.KeyRange(
from_bound=ydb.KeyBound.inclusive([1], key_type),
to_bound=ydb.KeyBound.exclusive([1000], key_type),
)
def callee(session):
with session.read_table(
"/local/users",
key_range=key_range,
columns=("id", "name"),
ordered=True,
) as it:
for result_set in it:
for row in result_set.rows:
print(row.id, row.name)
driver.table_client.retry_operation_sync(callee)
KeyBound.inclusive([value], type) — the bound row is included.
KeyBound.exclusive([value], type) — the bound row is excluded.
Pass None to from_bound or to_bound of KeyRange to mean
“from the beginning” or “to the end” of the table.
Parameters for read_table:
Parameter |
Description |
|---|---|
|
Full path to the table. |
|
|
|
Tuple of column names to return; empty tuple returns all columns. |
|
If |
|
Maximum number of rows to return. |
|
|
scan_query
The table-client scan_query methods are deprecated and emit a
DeprecationWarning. Use QuerySessionPool (or
QuerySessionPool) and stream results with session.execute()
for new code; see Query Service.
The examples below show legacy usage for applications that have not migrated yet.
scan_query executes a YQL query in streaming mode — the server sends result
chunks as they are produced without buffering the entire result set:
for result_set in driver.table_client.async_scan_query(
"SELECT id, name FROM users WHERE age > 25"
):
for row in result_set.rows:
print(row.id, row.name)
Unlike execute_with_retries from the Query service, scan_query does not
buffer results and cannot be used inside a transaction.
Pass parameters with a ScanQueryParameters object or a plain dict:
for result_set in driver.table_client.async_scan_query(
"DECLARE $min_age AS Uint32; SELECT id, name FROM users WHERE age > $min_age",
parameters={"$min_age": (25, ydb.PrimitiveType.Uint32)},
):
for row in result_set.rows:
print(row.id, row.name)
Async Usage
The async table client is accessed the same way via driver.table_client
on an async driver. All I/O methods become coroutines:
import asyncio
import ydb
async def main():
async with ydb.aio.Driver(
endpoint="grpc://localhost:2136", database="/local"
) as driver:
await driver.wait(timeout=5, fail_fast=True)
# Schema operations
await driver.table_client.create_table(
"/local/users",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("name", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
)
.with_primary_keys("id"),
)
# Bulk upsert
column_types = (
ydb.BulkUpsertColumns()
.add_column("id", ydb.PrimitiveType.Uint64)
.add_column("name", ydb.OptionalType(ydb.PrimitiveType.Utf8))
)
rows = [...]
await driver.table_client.bulk_upsert("/local/users", rows, column_types)
# Point read by primary key
key_types = ydb.BulkUpsertColumns().add_column("id", ydb.PrimitiveType.Uint64)
result_set = await driver.table_client.read_rows(
"/local/users", [{"id": 1}], key_types, columns=("id", "name")
)
for row in result_set.rows:
print(row.id, row.name)
# Describe
entry = await driver.table_client.describe_table("/local/users")
for col in entry.columns:
print(col.name, col.type)
# Describe a system view
sys_view = await driver.table_client.describe_system_view("/local/.sys/nodes")
print(sys_view.sys_view_name, sys_view.primary_key)
await driver.table_client.drop_table("/local/users")
asyncio.run(main())
Complete Example
Create a table with TTL, a secondary index, and custom partitioning; load data
with bulk_upsert; stream it back with read_table:
import ydb
from collections import namedtuple
Event = namedtuple("Event", ["id", "user_id", "kind", "created_at"])
with ydb.Driver(endpoint="grpc://localhost:2136", database="/local") as driver:
driver.wait(timeout=5, fail_fast=True)
# Create table
driver.table_client.create_table(
"/local/events",
ydb.TableDescription()
.with_columns(
ydb.Column("id", ydb.PrimitiveType.Uint64),
ydb.Column("user_id", ydb.OptionalType(ydb.PrimitiveType.Uint64)),
ydb.Column("kind", ydb.OptionalType(ydb.PrimitiveType.Utf8)),
ydb.Column("created_at", ydb.OptionalType(ydb.PrimitiveType.Timestamp)),
)
.with_primary_keys("id")
.with_indexes(
ydb.TableIndex("user_idx").with_index_columns("user_id")
)
.with_ttl(
ydb.TtlSettings().with_date_type_column(
"created_at",
expire_after_seconds=30 * 24 * 3600,
)
)
.with_partitioning_settings(
ydb.PartitioningSettings()
.with_min_partitions_count(4)
.with_max_partitions_count(64)
.with_partitioning_by_load(ydb.FeatureFlag.ENABLED)
),
)
# Bulk load
import datetime
now = datetime.datetime.now(tz=datetime.timezone.utc)
column_types = (
ydb.BulkUpsertColumns()
.add_column("id", ydb.PrimitiveType.Uint64)
.add_column("user_id", ydb.OptionalType(ydb.PrimitiveType.Uint64))
.add_column("kind", ydb.OptionalType(ydb.PrimitiveType.Utf8))
.add_column("created_at", ydb.OptionalType(ydb.PrimitiveType.Timestamp))
)
rows = [Event(i, i % 10, "click", now) for i in range(10000)]
driver.table_client.bulk_upsert("/local/events", rows, column_types)
# Stream all rows back
def read_all(session):
with session.read_table("/local/events", columns=("id", "user_id", "kind")) as it:
for result_set in it:
for row in result_set.rows:
print(row.id, row.user_id, row.kind)
driver.table_client.retry_operation_sync(read_all)
# Clean up
driver.table_client.drop_table("/local/events")