When you manually pre-split a YugabyteDB table, using SPLIT AT VALUES, you’re telling the database exactly how to divide its key space into tablets. This is a powerful capability, especially for avoiding hot shards, isolating high-traffic ranges, or pre-splitting time- or tenant-based data.
But there’s a catch.
The most authoritative way to verify those splits is by querying: yb_local_tablets()
Unfortunately, the tablet boundaries are exposed as DocDB-encoded bytea values (partition_key_start and partition_key_end).
While accurate, they’re not human-readable, which makes it hard to answer simple questions like:
● Which tablet owns
a = 200?● What does a split at
(200,5)actually mean?● Did my
'A'..'Z'text ranges split the way I expected?
In this tip, we’ll demystify tablet split boundaries by:
● decoding integer, timestamp and text range keys,
● explaining how composite primary keys (including
DESCcolumns) affect splits,● and building reusable queries and helpers that turn raw tablet metadata into clear, readable ranges.
The goal isn’t to reverse-engineer every possible DocDB encoding, but to give you practical, reliable tools you can use to validate real-world schemas.
Example 1: Composite INT keys and the meaning of (200,5)
Table definition
CREATE TABLE tbl (
a INT,
b INT,
PRIMARY KEY (a ASC, b DESC)
)
SPLIT AT VALUES ((100), (200), (200,5));
This creates four tablets, including a split inside the a = 200 range.
Why? Because the primary key ordering is:
(a ASC, b DESC)
Within a = 200, rows are ordered like:
(200, 100)
(200, 50)
(200, 6)
(200, 5) <-- split here
(200, 4)
(200, 1)
So (200,5) is not a new a range… it’s a secondary-key boundary inside the same a value.
Decoding integer split keys (ASC and DESC)
To decode tablet boundaries, we need to understand that YugabyteDB:
● flips the sign bit to preserve sort order
● inverts bits for
DESCcolumns
Here are the helper functions we’ll use.
INT32 (ASC)
CREATE OR REPLACE FUNCTION yb_decode_int32_asc_from_pk(pk BYTEA, start_at INT DEFAULT 0)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 4 THEN NULL
ELSE (
(
(get_byte(pk, start_at+1)::bigint << 24) +
(get_byte(pk, start_at+2)::bigint << 16) +
(get_byte(pk, start_at+3)::bigint << 8) +
get_byte(pk, start_at+4)::bigint
) - 2147483648::bigint
)::int
END;
$$;
INT32 (DESC)
CREATE OR REPLACE FUNCTION yb_decode_int32_desc_from_pk(pk BYTEA, start_at INT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 4 THEN NULL
ELSE (
(
(
(
(get_byte(pk, start_at+1)::bigint << 24) +
(get_byte(pk, start_at+2)::bigint << 16) +
(get_byte(pk, start_at+3)::bigint << 8) +
get_byte(pk, start_at+4)::bigint
) # 4294967295::bigint
) - 2147483648::bigint
)::int
)
END;
$$;
Viewing the tablet ranges for tbl
WITH t AS (
SELECT tablet_id,
yb_decode_int32_asc_from_pk(partition_key_start, 0) AS a_start,
yb_decode_int32_desc_from_pk(partition_key_start, 5) AS b_start,
yb_decode_int32_asc_from_pk(partition_key_end, 0) AS a_end,
yb_decode_int32_desc_from_pk(partition_key_end, 5) AS b_end,
partition_key_start
FROM yb_local_tablets()
WHERE table_name = 'tbl'
)
SELECT tablet_id,
format(
'(%s) .. (%s)',
CASE
WHEN a_start IS NULL THEN '-inf'
WHEN b_start IS NULL THEN format('%s', a_start)
ELSE format('%s,%s', a_start, b_start)
END,
CASE
WHEN a_end IS NULL THEN '+inf'
WHEN b_end IS NULL THEN format('%s', a_end)
ELSE format('%s,%s', a_end, b_end)
END
) AS split_range
FROM t
ORDER BY partition_key_start NULLS FIRST;
Example:
yugabyte=# SELECT tablet_id, partition_key_start, partition_key_end FROM yb_local_tablets() WHERE table_name = 'tbl';
tablet_id | partition_key_start | partition_key_end
----------------------------------+--------------------------+--------------------------
7f84b4c4fe144818b0294fad3fc58f28 | \x48800000c80021 | \x48800000c8657ffffffa21
9aaa661568bd45caa385096028cb77ac | \x48800000c8657ffffffa21 |
6de511e6cb2e4f6a97d40cd2213cd55c | \x48800000640021 | \x48800000c80021
cfe6218444a54b27bbb3b4628f61f5bd | | \x48800000640021
(4 rows)
yugabyte=# WITH t AS (
yugabyte(# SELECT tablet_id,
yugabyte(# yb_decode_int32_asc_from_pk(partition_key_start, 0) AS a_start,
yugabyte(# yb_decode_int32_desc_from_pk(partition_key_start, 5) AS b_start,
yugabyte(# yb_decode_int32_asc_from_pk(partition_key_end, 0) AS a_end,
yugabyte(# yb_decode_int32_desc_from_pk(partition_key_end, 5) AS b_end,
yugabyte(# partition_key_start
yugabyte(# FROM yb_local_tablets()
yugabyte(# WHERE table_name = 'tbl'
yugabyte(# )
yugabyte-# SELECT tablet_id,
yugabyte-# format(
yugabyte(# '(%s) .. (%s)',
yugabyte(# CASE
yugabyte(# WHEN a_start IS NULL THEN '-inf'
yugabyte(# WHEN b_start IS NULL THEN format('%s', a_start)
yugabyte(# ELSE format('%s,%s', a_start, b_start)
yugabyte(# END,
yugabyte(# CASE
yugabyte(# WHEN a_end IS NULL THEN '+inf'
yugabyte(# WHEN b_end IS NULL THEN format('%s', a_end)
yugabyte(# ELSE format('%s,%s', a_end, b_end)
yugabyte(# END
yugabyte(# ) AS split_range
yugabyte-# FROM t
yugabyte-# ORDER BY partition_key_start NULLS FIRST;
tablet_id | split_range
----------------------------------+-------------------
cfe6218444a54b27bbb3b4628f61f5bd | (-inf) .. (100)
6de511e6cb2e4f6a97d40cd2213cd55c | (100) .. (200)
7f84b4c4fe144818b0294fad3fc58f28 | (200) .. (200,5)
9aaa661568bd45caa385096028cb77ac | (200,5) .. (+inf)
(4 rows)
This confirms exactly how YugabyteDB interpreted the splits.
Example 2: TEXT range splits (A–Z style)
Table definition
CREATE TABLE users_by_lastname (
last_name TEXT,
user_id UUID,
info TEXT,
PRIMARY KEY (last_name ASC, user_id ASC)
)
SPLIT AT VALUES (
('G'),
('N'),
('T')
);
This creates four logical ranges:
●
(-inf .. 'G')→ roughly A–F●
('G' .. 'N')→ G–M●
('N' .. 'T')→ N–S●
('T' .. +inf)→ T–Z
Helper: decode TEXT split prefixes safely
DocDB string encodings can contain 0x00 bytes, which PostgreSQL text cannot hold. The function below strips them safely.
CREATE OR REPLACE FUNCTION yb_decode_text_prefix_from_pk(pk BYTEA)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) <= 2 THEN NULL
ELSE convert_from(
decode(
replace(
encode(
substring(pk FROM 2 FOR length(pk)-2),
'hex'
),
'00',
''
),
'hex'
),
'UTF8'
)
END;
$$;
Viewing TEXT split ranges:
WITH t AS (
SELECT tablet_id,
yb_decode_text_prefix_from_pk(partition_key_start) AS start_key,
yb_decode_text_prefix_from_pk(partition_key_end) AS end_key,
partition_key_start
FROM yb_local_tablets()
WHERE table_name = 'users_by_lastname'
)
SELECT tablet_id,
format(
'(%s) .. (%s)',
COALESCE(start_key, '-inf'),
COALESCE(end_key, '+inf')
) AS split_range
FROM t
ORDER BY partition_key_start NULLS FIRST;
Example:
yugabyte=# WITH t AS (
yugabyte(# SELECT tablet_id,
yugabyte(# yb_decode_text_prefix_from_pk(partition_key_start) AS start_key,
yugabyte(# yb_decode_text_prefix_from_pk(partition_key_end) AS end_key,
yugabyte(# partition_key_start
yugabyte(# FROM yb_local_tablets()
yugabyte(# WHERE table_name = 'users_by_lastname'
yugabyte(# )
yugabyte-# SELECT tablet_id,
yugabyte-# format(
yugabyte(# '(%s) .. (%s)',
yugabyte(# COALESCE(start_key, '-inf'),
yugabyte(# COALESCE(end_key, '+inf')
yugabyte(# ) AS split_range
yugabyte-# FROM t
yugabyte-# ORDER BY partition_key_start NULLS FIRST;
tablet_id | split_range
----------------------------------+---------------
0b3e56e8b79b4cdbbd15aa78b462aa8d | (-inf) .. (G)
dfb4ae72e75e4ceba357b40f09cc1928 | (G) .. (N)
0514033e1b2142b3bb5184e8c7a6fcda | (N) .. (T)
19b4d586f81d46c38882edb8f5721d00 | (T) .. (+inf)
(4 rows)
A universal helper: yb_tablet_ranges()
Below is a single, reusable function yb_tablet_ranges(regclass) that:
● reads the table’s PRIMARY KEY definition (columns + ASC/DESC),
● decodes
yb_local_tablets().partition_key_start/end,● supports these PK column types:
○ text / varchar
○ int2 / int4 / int8
○ date
○ timestamp / timestamptz
● returns human-readable ranges plus structured JSON for start/end bounds.
It’s designed for the common case of range-split points created by YSQL (like the examples above). For composite keys, it will decode multiple columns as long as the preceding columns are fixed-width (ints/dates/timestamps). If the first PK column is TEXT, it decodes that and stops (TEXT encoding is variable-length).
1️⃣ INT16 (SMALLINT) decoders
CREATE OR REPLACE FUNCTION yb_decode_int16_asc(pk BYTEA, start_at INT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 2 THEN NULL
ELSE (
(
(get_byte(pk, start_at+1)::bigint << 8) +
get_byte(pk, start_at+2)::bigint
) - 32768::bigint
)::int
END;
$$;
CREATE OR REPLACE FUNCTION yb_decode_int16_desc(pk BYTEA, start_at INT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 2 THEN NULL
ELSE (
(
(
(
(get_byte(pk, start_at+1)::bigint << 8) +
get_byte(pk, start_at+2)::bigint
) # 65535::bigint
) - 32768::bigint
)::int
)
END;
$$;
2️⃣ INT32 (INTEGER) decoders
CREATE OR REPLACE FUNCTION yb_decode_int32_asc(pk BYTEA, start_at INT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 4 THEN NULL
ELSE (
(
(get_byte(pk, start_at+1)::bigint << 24) +
(get_byte(pk, start_at+2)::bigint << 16) +
(get_byte(pk, start_at+3)::bigint << 8) +
get_byte(pk, start_at+4)::bigint
) - 2147483648::bigint
)::int
END;
$$;
CREATE OR REPLACE FUNCTION yb_decode_int32_desc(pk BYTEA, start_at INT)
RETURNS INT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 4 THEN NULL
ELSE (
(
(
(
(get_byte(pk, start_at+1)::bigint << 24) +
(get_byte(pk, start_at+2)::bigint << 16) +
(get_byte(pk, start_at+3)::bigint << 8) +
get_byte(pk, start_at+4)::bigint
) # 4294967295::bigint
) - 2147483648::bigint
)::int
)
END;
$$;
3️⃣ INT64 (BIGINT) decoders
CREATE OR REPLACE FUNCTION yb_decode_int64_asc(pk BYTEA, start_at INT)
RETURNS BIGINT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 8 THEN NULL
ELSE (
(
(
(get_byte(pk, start_at+1)::numeric * 72057594037927936) + -- 256^7
(get_byte(pk, start_at+2)::numeric * 281474976710656) + -- 256^6
(get_byte(pk, start_at+3)::numeric * 1099511627776) + -- 256^5
(get_byte(pk, start_at+4)::numeric * 4294967296) + -- 256^4
(get_byte(pk, start_at+5)::numeric * 16777216) + -- 256^3
(get_byte(pk, start_at+6)::numeric * 65536) + -- 256^2
(get_byte(pk, start_at+7)::numeric * 256) + -- 256^1
get_byte(pk, start_at+8)::numeric -- 256^0
)
- 9223372036854775808::numeric -- undo sign-flip (2^63)
)::bigint
)
END;
$$;
CREATE OR REPLACE FUNCTION yb_decode_int64_desc(pk BYTEA, start_at INT)
RETURNS BIGINT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 8 THEN NULL
ELSE (
(
(
18446744073709551615::numeric -- (2^64 - 1)
- (
(get_byte(pk, start_at+1)::numeric * 72057594037927936) +
(get_byte(pk, start_at+2)::numeric * 281474976710656) +
(get_byte(pk, start_at+3)::numeric * 1099511627776) +
(get_byte(pk, start_at+4)::numeric * 4294967296) +
(get_byte(pk, start_at+5)::numeric * 16777216) +
(get_byte(pk, start_at+6)::numeric * 65536) +
(get_byte(pk, start_at+7)::numeric * 256) +
get_byte(pk, start_at+8)::numeric
)
)
- 9223372036854775808::numeric -- undo sign-flip (2^63)
)::bigint
)
END;
$$;
4️⃣ DATE decoders
CREATE OR REPLACE FUNCTION yb_decode_date_asc(pk BYTEA, start_at INT)
RETURNS DATE
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 4 THEN NULL
ELSE ('2000-01-01'::date + yb_decode_int32_asc(pk, start_at))
END;
$$;
CREATE OR REPLACE FUNCTION yb_decode_date_desc(pk BYTEA, start_at INT)
RETURNS DATE
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 4 THEN NULL
ELSE ('2000-01-01'::date + yb_decode_int32_desc(pk, start_at))
END;
$$;
5️⃣ TIMESTAMP (without time zone) decoders
CREATE OR REPLACE FUNCTION yb_decode_timestamp_asc(pk BYTEA, start_at INT)
RETURNS TIMESTAMP
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 8 THEN NULL
ELSE ('2000-01-01'::timestamp
+ (yb_decode_int64_asc(pk, start_at) * interval '1 microsecond'))
END;
$$;
CREATE OR REPLACE FUNCTION yb_decode_timestamp_desc(pk BYTEA, start_at INT)
RETURNS TIMESTAMP
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 8 THEN NULL
ELSE ('2000-01-01'::timestamp
+ (yb_decode_int64_desc(pk, start_at) * interval '1 microsecond'))
END;
$$;
6️⃣ TIMESTAMPTZ decoders
CREATE OR REPLACE FUNCTION yb_decode_timestamptz_asc(pk BYTEA, start_at INT)
RETURNS TIMESTAMPTZ
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 8 THEN NULL
ELSE ('2000-01-01 00:00:00+00'::timestamptz
+ (yb_decode_int64_asc(pk, start_at) * interval '1 microsecond'))
END;
$$;
CREATE OR REPLACE FUNCTION yb_decode_timestamptz_desc(pk BYTEA, start_at INT)
RETURNS TIMESTAMPTZ
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) < start_at + 1 + 8 THEN NULL
ELSE ('2000-01-01 00:00:00+00'::timestamptz
+ (yb_decode_int64_desc(pk, start_at) * interval '1 microsecond'))
END;
$$;
7️⃣ TEXT / VARCHAR prefix decoder (for A–Z style splits)
CREATE OR REPLACE FUNCTION yb_decode_text_prefix_from_pk(pk BYTEA)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN pk IS NULL OR length(pk) <= 2 THEN NULL
ELSE convert_from(
decode(
replace(
encode(
substring(pk FROM 2 FOR length(pk)-2),
'hex'
),
'00',
''
),
'hex'
),
'UTF8'
)
END;
$$;
The main function: yb_tablet_ranges()
CREATE OR REPLACE FUNCTION yb_tablet_ranges(p_table regclass)
RETURNS TABLE (
tablet_id TEXT,
range_start JSONB,
range_end JSONB,
split_range TEXT
)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_relid oid := p_table;
v_ns text;
v_rel text;
-- PK metadata (filtered to real columns, aligned to indoption)
pk_attnums smallint[];
pk_indoption int[];
pk_types oid[];
pk_names text[];
-- Tablet row
r record;
-- Decoding temp
s_json jsonb;
e_json jsonb;
-- offsets into DocDB key (bytea)
off int;
-- per-column decoded values
sval_text text;
eval_text text;
-- loop idx
i int;
-- helpers
is_desc boolean;
typ oid;
attname text;
-- constants: Postgres indoption bits
INDOPTION_DESC constant int := 1;
BEGIN
-- Resolve schema + table name from regclass
SELECT n.nspname, c.relname
INTO v_ns, v_rel
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = v_relid;
IF v_rel IS NULL THEN
RAISE EXCEPTION 'Table % not found', p_table;
END IF;
-- Build PK column list from pg_index, filtering out non-column entries (attnum <= 0)
SELECT array_agg(attnum ORDER BY ord),
array_agg(opt ORDER BY ord)
INTO pk_attnums, pk_indoption
FROM (
SELECT ik.attnum::smallint AS attnum,
io.opt::int AS opt,
ik.ord AS ord
FROM pg_index idx
JOIN unnest(idx.indkey) WITH ORDINALITY AS ik(attnum, ord) ON true
JOIN unnest(idx.indoption) WITH ORDINALITY AS io(opt, ord) ON io.ord = ik.ord
WHERE idx.indrelid = v_relid
AND idx.indisprimary
AND ik.attnum > 0
) s;
IF pk_attnums IS NULL OR array_length(pk_attnums, 1) IS NULL THEN
RAISE EXCEPTION 'Table %.% has no resolvable PRIMARY KEY columns', v_ns, v_rel;
END IF;
-- Resolve PK column names + types in order
pk_names := ARRAY[]::text[];
pk_types := ARRAY[]::oid[];
FOR i IN 1..array_length(pk_attnums, 1) LOOP
SELECT a.attname, a.atttypid
INTO attname, typ
FROM pg_attribute a
WHERE a.attrelid = v_relid
AND a.attnum = pk_attnums[i];
IF attname IS NULL OR typ IS NULL THEN
RAISE EXCEPTION 'Could not resolve PK column metadata for attnum % on %.%',
pk_attnums[i], v_ns, v_rel;
END IF;
pk_names := pk_names || attname;
pk_types := pk_types || typ;
END LOOP;
-- Iterate tablets for this table
FOR r IN
SELECT lt.tablet_id,
lt.partition_key_start,
lt.partition_key_end
FROM yb_local_tablets() lt
WHERE lt.ysql_schema_name = v_ns
AND lt.table_name = v_rel
ORDER BY lt.partition_key_start NULLS FIRST
LOOP
s_json := '{}'::jsonb;
e_json := '{}'::jsonb;
-- -------------------------
-- Decode START boundary
-- -------------------------
off := 0;
IF r.partition_key_start IS NOT NULL AND length(r.partition_key_start) > 0 THEN
FOR i IN 1..array_length(pk_types, 1) LOOP
is_desc := (pk_indoption[i] & INDOPTION_DESC) = INDOPTION_DESC;
typ := pk_types[i];
attname := pk_names[i];
sval_text := NULL;
-- TEXT / VARCHAR: decode prefix and stop (variable length)
IF typ IN ('text'::regtype::oid, 'varchar'::regtype::oid) THEN
sval_text := yb_decode_text_prefix_from_pk(r.partition_key_start);
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text), true);
EXIT;
ELSIF typ = 'int2'::regtype::oid THEN
sval_text := (CASE WHEN is_desc THEN yb_decode_int16_desc(r.partition_key_start, off)
ELSE yb_decode_int16_asc (r.partition_key_start, off)
END)::text;
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text::int), true);
off := off + 1 + 2;
ELSIF typ = 'int4'::regtype::oid THEN
sval_text := (CASE WHEN is_desc THEN yb_decode_int32_desc(r.partition_key_start, off)
ELSE yb_decode_int32_asc (r.partition_key_start, off)
END)::text;
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text::int), true);
off := off + 1 + 4;
ELSIF typ = 'int8'::regtype::oid THEN
sval_text := (CASE WHEN is_desc THEN yb_decode_int64_desc(r.partition_key_start, off)
ELSE yb_decode_int64_asc (r.partition_key_start, off)
END)::text;
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text::bigint), true);
off := off + 1 + 8;
ELSIF typ = 'date'::regtype::oid THEN
sval_text := (CASE WHEN is_desc THEN yb_decode_date_desc(r.partition_key_start, off)
ELSE yb_decode_date_asc (r.partition_key_start, off)
END)::text;
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text::date), true);
off := off + 1 + 4;
ELSIF typ = 'timestamp'::regtype::oid THEN
sval_text := (CASE WHEN is_desc THEN yb_decode_timestamp_desc(r.partition_key_start, off)
ELSE yb_decode_timestamp_asc (r.partition_key_start, off)
END)::text;
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text::timestamp), true);
off := off + 1 + 8;
ELSIF typ = 'timestamptz'::regtype::oid THEN
sval_text := (CASE WHEN is_desc THEN yb_decode_timestamptz_desc(r.partition_key_start, off)
ELSE yb_decode_timestamptz_asc (r.partition_key_start, off)
END)::text;
IF sval_text IS NULL THEN EXIT; END IF;
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb(sval_text::timestamptz), true);
off := off + 1 + 8;
ELSE
s_json := jsonb_set(s_json, ARRAY[attname], to_jsonb('[unsupported type]'::text), true);
EXIT;
END IF;
END LOOP;
END IF;
-- -------------------------
-- Decode END boundary
-- -------------------------
off := 0;
IF r.partition_key_end IS NOT NULL AND length(r.partition_key_end) > 0 THEN
FOR i IN 1..array_length(pk_types, 1) LOOP
is_desc := (pk_indoption[i] & INDOPTION_DESC) = INDOPTION_DESC;
typ := pk_types[i];
attname := pk_names[i];
eval_text := NULL;
IF typ IN ('text'::regtype::oid, 'varchar'::regtype::oid) THEN
eval_text := yb_decode_text_prefix_from_pk(r.partition_key_end);
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text), true);
EXIT;
ELSIF typ = 'int2'::regtype::oid THEN
eval_text := (CASE WHEN is_desc THEN yb_decode_int16_desc(r.partition_key_end, off)
ELSE yb_decode_int16_asc (r.partition_key_end, off)
END)::text;
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text::int), true);
off := off + 1 + 2;
ELSIF typ = 'int4'::regtype::oid THEN
eval_text := (CASE WHEN is_desc THEN yb_decode_int32_desc(r.partition_key_end, off)
ELSE yb_decode_int32_asc (r.partition_key_end, off)
END)::text;
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text::int), true);
off := off + 1 + 4;
ELSIF typ = 'int8'::regtype::oid THEN
eval_text := (CASE WHEN is_desc THEN yb_decode_int64_desc(r.partition_key_end, off)
ELSE yb_decode_int64_asc (r.partition_key_end, off)
END)::text;
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text::bigint), true);
off := off + 1 + 8;
ELSIF typ = 'date'::regtype::oid THEN
eval_text := (CASE WHEN is_desc THEN yb_decode_date_desc(r.partition_key_end, off)
ELSE yb_decode_date_asc (r.partition_key_end, off)
END)::text;
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text::date), true);
off := off + 1 + 4;
ELSIF typ = 'timestamp'::regtype::oid THEN
eval_text := (CASE WHEN is_desc THEN yb_decode_timestamp_desc(r.partition_key_end, off)
ELSE yb_decode_timestamp_asc (r.partition_key_end, off)
END)::text;
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text::timestamp), true);
off := off + 1 + 8;
ELSIF typ = 'timestamptz'::regtype::oid THEN
eval_text := (CASE WHEN is_desc THEN yb_decode_timestamptz_desc(r.partition_key_end, off)
ELSE yb_decode_timestamptz_asc (r.partition_key_end, off)
END)::text;
IF eval_text IS NULL THEN EXIT; END IF;
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb(eval_text::timestamptz), true);
off := off + 1 + 8;
ELSE
e_json := jsonb_set(e_json, ARRAY[attname], to_jsonb('[unsupported type]'::text), true);
EXIT;
END IF;
END LOOP;
END IF;
-- Final output
tablet_id := r.tablet_id;
range_start := NULLIF(s_json, '{}'::jsonb);
range_end := NULLIF(e_json, '{}'::jsonb);
split_range := format(
'(%s) .. (%s)',
COALESCE(range_start::text, '-inf'),
COALESCE(range_end::text, '+inf')
);
RETURN NEXT;
END LOOP;
END;
$$;
3) Usage examples
Your (a ASC, b DESC) split example
SELECT * FROM yb_tablet_ranges('public.tbl'::regclass);
Example:
yugabyte=# SELECT * FROM yb_tablet_ranges('public.tbl'::regclass);
tablet_id | range_start | range_end | split_range
----------------------------------+--------------------+--------------------+--------------------------------------
cfe6218444a54b27bbb3b4628f61f5bd | | {"a": 100} | (-inf) .. ({"a": 100})
6de511e6cb2e4f6a97d40cd2213cd55c | {"a": 100} | {"a": 200} | ({"a": 100}) .. ({"a": 200})
7f84b4c4fe144818b0294fad3fc58f28 | {"a": 200} | {"a": 200, "b": 5} | ({"a": 200}) .. ({"a": 200, "b": 5})
9aaa661568bd45caa385096028cb77ac | {"a": 200, "b": 5} | | ({"a": 200, "b": 5}) .. (+inf)
(4 rows)
Your TEXT A–Z range example
SELECT * FROM yb_tablet_ranges('public.users_by_lastname'::regclass);
Example:
yugabyte=# SELECT * FROM yb_tablet_ranges('public.users_by_lastname'::regclass);
tablet_id | range_start | range_end | split_range
----------------------------------+--------------------+--------------------+----------------------------------------------
0b3e56e8b79b4cdbbd15aa78b462aa8d | | {"last_name": "G"} | (-inf) .. ({"last_name": "G"})
dfb4ae72e75e4ceba357b40f09cc1928 | {"last_name": "G"} | {"last_name": "N"} | ({"last_name": "G"}) .. ({"last_name": "N"})
0514033e1b2142b3bb5184e8c7a6fcda | {"last_name": "N"} | {"last_name": "T"} | ({"last_name": "N"}) .. ({"last_name": "T"})
19b4d586f81d46c38882edb8f5721d00 | {"last_name": "T"} | | ({"last_name": "T"}) .. (+inf)
(4 rows)
A date/timestamp split example
CREATE TABLE events_by_day (
event_day date,
ts timestamptz,
id int,
PRIMARY KEY (event_day ASC, ts DESC, id ASC)
) SPLIT AT VALUES (
('2025-01-01'),
('2025-02-01'),
('2025-02-01', '2025-02-01 00:00:00+00')
);
SELECT * FROM yb_tablet_ranges('public.events_by_day'::regclass);
yugabyte=# SELECT * FROM yb_tablet_ranges('public.events_by_day'::regclass);
tablet_id | range_start | range_end | split_range
----------------------------------+----------------------------------------------------------------+----------------------------------------------------------------+---------------------------------------------------------------------------------------------------
a4a12f289d7248a2bac754a2861a2c85 | | {"event_day": "2025-01-01"} | (-inf) .. ({"event_day": "2025-01-01"})
5973753c59a4417d93b16d9cf1105279 | {"event_day": "2025-01-01"} | {"event_day": "2025-02-01"} | ({"event_day": "2025-01-01"}) .. ({"event_day": "2025-02-01"})
1660bd3ed2a14e8e9b004a44297495e7 | {"event_day": "2025-02-01"} | {"ts": "2025-02-01T00:00:00+00:00", "event_day": "2025-02-01"} | ({"event_day": "2025-02-01"}) .. ({"ts": "2025-02-01T00:00:00+00:00", "event_day": "2025-02-01"})
a2dd840a516d4e00bad56ec9a78b5b59 | {"ts": "2025-02-01T00:00:00+00:00", "event_day": "2025-02-01"} | | ({"ts": "2025-02-01T00:00:00+00:00", "event_day": "2025-02-01"}) .. (+inf)
(4 rows)
Conclusion
Tablet splits are one of YugabyteDB’s most powerful tools for shaping data distribution, but they’re also one of the hardest things to see. Once a table is split, the truth lives inside yb_local_tablets() as DocDB-encoded bytea values—correct, but not exactly human-friendly.
In this tip, we turned those opaque bytes back into something you can reason about:
● We demystified composite split points like
(200,5), showing how they represent precise boundaries inside a primary-key prefix, not “mystery rows”.● We handled ASC and DESC ordering correctly, including secondary and tertiary key columns.
● We decoded real-world key types—integers, text prefixes, dates, timestamps, and timestamptz—using the same ordering rules YugabyteDB applies internally.
● And we wrapped it all into a reusable helper,
yb_tablet_ranges(), that lets you inspect tablet ownership with a single query.
As always, treat tablet metadata as a diagnostic and validation tool, not application logic. But when you need to understand why data is landing where it is, or prove that a split is working as designed, having a clear, human-readable view of tablet ranges makes all the difference.
Have Fun!
