Decode yb_local_tablets() Split Ranges into Human-Readable Bounds

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 DESC columns) 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 DESC columns

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!

When your flight gets delayed so long due to mechanical issues that they replace the entire plane… and you realize driving home would’ve only taken 6 hours. Worth it. Probably. 😬✈️