Exact row counts are common in migrations, validation, and operational checks. In YugabyteDB, SELECT COUNT(*) is correct and already distributed, but it can still take a long time on very large tables.
This tip shows two fast, exact row count options for range-sharded tables:
1. Option A (recommended): count each tablet key range in parallel using a client-side script and sum the results.
2. Option B (SQL-only): generate a
UNION ALLof per-rangeCOUNT(*)subqueries andSUM()them in a single statement (works best when YSQL parallel workers are tuned).
2025.2.0.0). Configuration defaults and behavior may differ in earlier releases.
What is range sharding in YugabyteDB?
YugabyteDB stores data in tablets (contiguous pieces of the keyspace). With range sharding, rows are assigned to tablets based on ordered primary key ranges, not a hash. Range sharding preserves key order and makes range predicates (for example, id >= X AND id < Y) naturally target specific tablets.
A range-sharded table with split points at 10M, 20M, … can have tablet ranges like:
●
(-inf) .. (10,000,000)●
(10,000,000) .. (20,000,000)● …
●
(90,000,000) .. (+inf)
This matters for counting because an exact row count can be expressed as:
total_rows = count(range1) + count(range2) + … + count(rangeN)
🔑 Key takeaway: When parallel range counts beat COUNT(*)
- ● Range sharding splits ordered primary keys into contiguous tablet ranges.
- ●
SELECT COUNT(*) is already distributed, but can still be slow on very large tables.
- ● Counting each tablet range independently and in parallel can reduce wall-clock time.
- ● This technique helps only when a table has multiple range tablets.
- ● It is most effective for large tables (tens or hundreds of millions of rows).
SELECT COUNT(*) is already distributed, but can still be slow on very large tables.Prerequisites
This tip assumes you have the helper function:
●
yb_tablet_ranges(regclass)
…from the YugabyteDB Tip, Decode yb_local_tablets() Split Ranges into Human-Readable Bounds, that converts tablet split ranges into human-readable bounds. (That helper is what makes it easy to build correct WHERE predicates per tablet range.)
When to use this technique
Use these options when:
● The table is range-sharded (
PRIMARY KEY (... ASC)with meaningful range splits)● The table has multiple tablets (otherwise there is nothing to parallelize)
● You need an exact count (not an estimate)
● The table is large (tens to hundreds of millions of rows)
Do not use this technique when:
● The table is hash sharded (use the
yb_hash_codeapproach instead)● The table has only one tablet (you’ll see
(-inf) .. (+inf)only)● The table is small (fan-out overhead dominates)
Demo setup (range splits + 100M rows)
Create a range-sharded table with 10M splits
For this demo we choose 10M rows per tablet. This creates 10 tablets, which is enough parallelism to matter without creating excessive overhead.
DROP TABLE IF EXISTS public.test;
CREATE TABLE public.test (
id BIGINT NOT NULL,
c1 TEXT,
PRIMARY KEY (id ASC)
)
SPLIT AT VALUES (
(10000000),
(20000000),
(30000000),
(40000000),
(50000000),
(60000000),
(70000000),
(80000000),
(90000000)
);
Confirm you actually have multiple tablet ranges
SELECT
range_start->>'id' AS start_id,
range_end->>'id' AS end_id,
split_range
FROM yb_tablet_ranges('public.test'::regclass)
ORDER BY split_range;
Example output:
start_id | end_id | split_range
----------+----------+------------------------------------------
| 10000000 | (-inf) .. ({"id": 10000000})
10000000 | 20000000 | ({"id": 10000000}) .. ({"id": 20000000})
20000000 | 30000000 | ({"id": 20000000}) .. ({"id": 30000000})
30000000 | 40000000 | ({"id": 30000000}) .. ({"id": 40000000})
40000000 | 50000000 | ({"id": 40000000}) .. ({"id": 50000000})
50000000 | 60000000 | ({"id": 50000000}) .. ({"id": 60000000})
60000000 | 70000000 | ({"id": 60000000}) .. ({"id": 70000000})
70000000 | 80000000 | ({"id": 70000000}) .. ({"id": 80000000})
80000000 | 90000000 | ({"id": 80000000}) .. ({"id": 90000000})
90000000 | | ({"id": 90000000}) .. (+inf)
(10 rows)
If you see only:
(-inf) .. (+inf)
… you have one tablet and nothing to parallelize.
Load 100 million rows (chunked), with “random-looking” text
Avoid huge single-statement inserts that can hit temp_file_limit. Use chunked inserts. This uses deterministic pseudo-random strings via md5(id) (fast and repeatable):
TRUNCATE TABLE public.test;
DO $$
DECLARE
lo bigint := 1;
hi bigint;
BEGIN
WHILE lo <= 100000000 LOOP
hi := LEAST(lo + 10000000 - 1, 100000000);
INSERT INTO public.test (id, c1)
SELECT
g,
substr(md5(g::text), 1, 8)
FROM generate_series(lo, hi) AS g;
lo := hi + 1;
END LOOP;
END $$;
Baseline: plain COUNT(*)
\timing on
SELECT COUNT(*) FROM public.test;
Example output:
count
-----------
100000000
(1 row)
Time: 23661.063 ms (00:23.661)
That’s almost 24 seconds.
Option A (recommended): client-side fan-out per tablet range (script)
This option scales best as data grows because it uses multiple independent SQL sessions. It’s not constrained by per-query parallel worker limits, and it provides operational advantages:
● explicit parallelism control (
-P)● per-range visibility (distribution)
● failure isolation + easy retry per range
yb_range_row_count.sh
set -euo pipefail
# yb_range_row_count.sh
# Exact row count for a RANGE-sharded YSQL table by counting each tablet key-range in parallel.
#
# Requires yb_tablet_ranges(regclass) from:
# https://yugabytedb.tips/decode-yb_local_tablets-split-ranges-into-human-readable-bounds/
usage() {
cat <<'EOF'
Usage:
./yb_range_row_count.sh -t schema.table -h host -d db -U user [options]
Required:
-t schema.table
-h host
-d database
-U user
Options:
-p port (default: 5433)
-P parallelism (default: 8)
--ysqlsh PATH (default: ysqlsh)
--where SQL (extra predicate appended with AND)
--quiet print ONLY total row count (machine-friendly)
--time-count print count-phase elapsed time (ms) to stderr
--no-validate skip PK/type/HASH checks (faster setup, less safety)
Examples:
PGPASSWORD=yugabyte ./yb_range_row_count.sh -t public.test -h 127.0.0.1 -d yugabyte -U yugabyte
PGPASSWORD=yugabyte ./yb_range_row_count.sh -t public.test -h 127.0.0.1 -d yugabyte -U yugabyte --time-count -P 10
PGPASSWORD=yugabyte ./yb_range_row_count.sh -t public.test -h 127.0.0.1 -d yugabyte -U yugabyte --quiet --time-count -P 10
EOF
}
# Defaults
PORT="5433"
PARALLELISM="8"
YSQLSH_BIN="ysqlsh"
TABLE=""
HOST=""
DB=""
DB_USER=""
EXTRA_WHERE=""
QUIET="0"
TIME_COUNT="0"
NO_VALIDATE="0"
while [[ $# -gt 0 ]]; do
case "$1" in
-t) TABLE="${2:-}"; shift 2;;
-h) HOST="${2:-}"; shift 2;;
-p) PORT="${2:-}"; shift 2;;
-d) DB="${2:-}"; shift 2;;
-U) DB_USER="${2:-}"; shift 2;;
-P) PARALLELISM="${2:-}"; shift 2;;
--ysqlsh) YSQLSH_BIN="${2:-}"; shift 2;;
--where) EXTRA_WHERE="${2:-}"; shift 2;;
--quiet) QUIET="1"; shift 1;;
--time-count) TIME_COUNT="1"; shift 1;;
--no-validate) NO_VALIDATE="1"; shift 1;;
-?|--help) usage; exit 0;;
*) echo "Unknown arg: $1" >&2; usage; exit 2;;
esac
done
if [[ -z "$TABLE" || -z "$HOST" || -z "$DB" || -z "$DB_USER" ]]; then
usage
exit 2
fi
if [[ "$TABLE" != *.* ]]; then
echo "ERROR: -t must be schema.table (got: $TABLE)" >&2
exit 2
fi
SCHEMA="${TABLE%%.*}"
TNAME="${TABLE#*.}"
YSQLSH_FLAGS=(-h "$HOST" -p "$PORT" -U "$DB_USER" -d "$DB" -X -q -A -t -v ON_ERROR_STOP=1)
y() { "$YSQLSH_BIN" "${YSQLSH_FLAGS[@]}" -c "$1"; }
TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
# Minimal checks (fast)
tc="$(y "SELECT COUNT(*) FROM pg_catalog.pg_tables WHERE schemaname='${SCHEMA}' AND tablename='${TNAME}';" | tr -d '[:space:]')"
if [[ "$tc" != "1" ]]; then
echo "ERROR: Table not found: ${TABLE}" >&2
exit 1
fi
has_fn="$(y "SELECT COUNT(*) FROM pg_proc WHERE proname='yb_tablet_ranges';" | tr -d '[:space:]')"
if [[ "$has_fn" == "0" ]]; then
echo "ERROR: Missing function yb_tablet_ranges(regclass). See decode tip." >&2
exit 1
fi
# Find PK column name (single-col only). Keep this even in --no-validate because we need the json key.
pk_cols="$(
y "
SELECT a.attname
FROM pg_index i
JOIN pg_class c ON c.oid=i.indrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum = ANY(i.indkey)
WHERE n.nspname='${SCHEMA}' AND c.relname='${TNAME}' AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)
LIMIT 2;
"
)"
pk_cols_count="$(printf "%s\n" "$pk_cols" | sed '/^\s*$/d' | wc -l | tr -d '[:space:]')"
if [[ "$pk_cols_count" != "1" ]]; then
echo "ERROR: single-column PRIMARY KEY required (found ${pk_cols_count})." >&2
exit 1
fi
PK_COL="$(printf "%s\n" "$pk_cols" | head -n1 | tr -d '[:space:]')"
pk_type=""
if [[ "$NO_VALIDATE" == "0" ]]; then
pk_type="$(y "
SELECT format_type(a.atttypid, a.atttypmod)
FROM pg_attribute a
JOIN pg_class c ON c.oid=a.attrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='${SCHEMA}' AND c.relname='${TNAME}' AND a.attname='${PK_COL}';
" | tr -d '[:space:]')"
if [[ "$pk_type" != "bigint" && "$pk_type" != "int8" ]]; then
echo "ERROR: PK column ${PK_COL} must be BIGINT/int8 for this script (found ${pk_type})." >&2
exit 1
fi
pk_def="$(y "
SELECT pg_get_indexdef(i.indexrelid)
FROM pg_index i
JOIN pg_class c ON c.oid=i.indrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='${SCHEMA}' AND c.relname='${TNAME}' AND i.indisprimary;
")"
if echo "$pk_def" | grep -qi "HASH"; then
echo "ERROR: table appears HASH-sharded; this script is for RANGE PKs." >&2
exit 1
fi
fi
# Pull ranges (tablet_id|start|end|split_range)
ranges_file="${TMPDIR}/ranges.txt"
y "
SELECT
tablet_id || '|' ||
COALESCE(range_start->>'${PK_COL}', '') || '|' ||
COALESCE(range_end->>'${PK_COL}', '') || '|' ||
split_range
FROM yb_tablet_ranges('${SCHEMA}.${TNAME}'::regclass)
ORDER BY split_range;
" > "$ranges_file"
num_ranges="$(grep -c '|' "$ranges_file" || true)"
if [[ "$num_ranges" -le 0 ]]; then
echo "ERROR: No ranges found via yb_tablet_ranges()." >&2
exit 1
fi
# Verbose header
if [[ "$QUIET" == "0" ]]; then
echo "Table: ${TABLE}"
if [[ -n "$pk_type" ]]; then
echo "PK: ${PK_COL} (${pk_type})"
else
echo "PK: ${PK_COL}"
fi
echo "Host: ${HOST}:${PORT} DB: ${DB} User: ${DB_USER}"
echo "Par: ${PARALLELISM}"
if [[ -n "$EXTRA_WHERE" ]]; then
echo "Where: ${EXTRA_WHERE}"
fi
echo
echo "Ranges: ${num_ranges}"
if [[ "$num_ranges" -eq 1 ]]; then
echo "NOTE: Only 1 tablet range found (-inf..+inf). Nothing to parallelize."
fi
echo
fi
# Worker: outputs either "count" (quiet) or "tablet_id|count|split_range" (verbose)
export YSQLSH_BIN HOST PORT DB DB_USER TABLE PK_COL EXTRA_WHERE QUIET
count_one() {
local line="$1"
local tid start end split
IFS="|" read -r tid start end split <<< "$line"
local w="TRUE"
[[ -n "$start" ]] && w="${PK_COL} >= ${start}"
[[ -n "$end" ]] && w="${w} AND ${PK_COL} < ${end}"
[[ -n "$EXTRA_WHERE" ]] && w="${w} AND (${EXTRA_WHERE})"
if [[ "$QUIET" == "1" ]]; then
# Return just the count (one number)
"$YSQLSH_BIN" -h "$HOST" -p "$PORT" -U "$DB_USER" -d "$DB" -X -q -A -t -v ON_ERROR_STOP=1 \
-c "SELECT COUNT(*) FROM ${TABLE} WHERE ${w};"
else
# Return tablet_id|count|split_range for breakdown
"$YSQLSH_BIN" -h "$HOST" -p "$PORT" -U "$DB_USER" -d "$DB" -X -q -A -t -v ON_ERROR_STOP=1 \
-c "SELECT '${tid}' AS tablet_id, COUNT(*) AS c, '${split}' AS split_range FROM ${TABLE} WHERE ${w};"
fi
}
export -f count_one
# Time only the count phase if requested
start_ns=0
if [[ "$TIME_COUNT" == "1" ]]; then
start_ns="$(date +%s%N)"
fi
if [[ "$QUIET" == "1" ]]; then
# Run counts in parallel, sum raw numbers
total="$(
cat "$ranges_file" \
| xargs -I {} -P "$PARALLELISM" bash -c 'count_one "$1"' _ {} \
| awk '{s += $1} END {printf "%.0f\n", s}'
)"
else
# Run counts in parallel, capture breakdown, sum counts
counts_file="${TMPDIR}/counts.txt"
cat "$ranges_file" \
| xargs -I {} -P "$PARALLELISM" bash -c 'count_one "$1"' _ {} \
| tee "$counts_file" >/dev/null
total="$(awk -F'|' '{s += $2} END {printf "%.0f\n", s}' "$counts_file")"
fi
if [[ "$TIME_COUNT" == "1" ]]; then
end_ns="$(date +%s%N)"
ms="$(( (end_ns - start_ns) / 1000000 ))"
echo "count_phase_ms=${ms}" >&2
fi
# Output
if [[ "$QUIET" == "1" ]]; then
echo "$total"
else
echo
echo "Total row count: ${total}"
echo
if [[ -n "${counts_file:-}" && -s "${counts_file:-}" ]]; then
echo "Per-range breakdown (tablet_id | count | split_range):"
column -t -s'|' "$counts_file" | sed 's/^/ /'
fi
fi
Run it
PGPASSWORD=yugabyte \
./yb_range_row_count.sh \
-t public.test \
-h 127.0.0.1 \
-d yugabyte \
-U yugabyte \
--time-count \
-P 10
Output (verbose mode)
In default mode, the script prints:
● table, PK, connection info
● number of ranges (tablets)
● total row count
● per-range breakdown (tablet_id | count | split_range)
Output (quiet mode)
For automation/benchmarking:
PGPASSWORD=yugabyte \
./yb_range_row_count.sh \
-t public.test \
-h 127.0.0.1 \
-d yugabyte \
-U yugabyte \
--quiet \
--time-count \
-P 10
This prints:
●
count_phase_ms=...to stderr (optional)● the total count to stdout
Example output:
count_phase_ms=9697
100000000
In this test, parallel range counting reduced the wall-clock time from ~23.6 seconds to ~9.7 seconds… a 2.4× speedup (nearly 60% faster) compared to a plain COUNT(*).
Option B: SQL-only UNION ALL + SUM (parallel query)
This option avoids a shell script by running one SQL statement that:
● runs one
COUNT(*)per range predicate● combines them with
UNION ALL● sums the results
Important: tune YSQL parallel worker settings
This approach is limited by YSQL’s parallel worker framework. In many environments, defaults like max_parallel_workers_per_gather=2 will cap parallelism and reduce performance.
For experiments, try:
SET max_parallel_workers = 16;
SET max_parallel_workers_per_gather = 16;
Example UNION ALL query (10 ranges)
\timing on
SELECT SUM(c) AS total_row_count
FROM (
SELECT COUNT(*) AS c FROM public.test WHERE id < 10000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 10000000 AND id < 20000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 20000000 AND id < 30000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 30000000 AND id < 40000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 40000000 AND id < 50000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 50000000 AND id < 60000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 60000000 AND id < 70000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 70000000 AND id < 80000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 80000000 AND id < 90000000
UNION ALL
SELECT COUNT(*) FROM public.test WHERE id >= 90000000
) counts;
Sample output:
total_row_count
-----------------
100000000
(1 row)
Time: 8335.301 ms (00:08.335)
In this test, parallel range counting reduced execution time from ~23.7 seconds to ~8.3 seconds… an almost 3× speedup (about 65% faster) compared to a plain COUNT(*).
Why Option B can match Option A in some cases
If parallel workers are tuned high enough and the environment allows them (worker caps increased), this query can perform similarly to the script for large tables. It’s still one coordinated query, but it can distribute work across parallel workers.
Why Option A still wins long-term
Option A uses multiple independent sessions and typically scales better as:
● tablet count grows
● range count grows
● worker caps constrain a single query
● you want per-range observability and retry
Bonus option: dynamic UNION ALL row count function (fun + useful)
If you want “one call” inside SQL that dynamically discovers tablet ranges and executes the generated UNION ALL query, here’s a function that:
● accepts schema, table, and desired parallelism
● discovers tablet ranges via
yb_tablet_ranges()● constructs the UNION ALL query automatically
● runs it and returns the exact row count
CREATE OR REPLACE FUNCTION public.yb_range_union_row_count(
p_schema text,
p_table text,
p_parallelism int DEFAULT 8
)
RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
fqtn text := format('%I.%I', p_schema, p_table);
rel regclass;
pk_col text;
pk_type text;
pk_def text;
union_sql text;
total bigint;
BEGIN
rel := to_regclass(fqtn);
IF rel IS NULL THEN
RAISE EXCEPTION 'Table not found: %', fqtn;
END IF;
-- single-column PK only
SELECT a.attname
INTO pk_col
FROM pg_index i
JOIN pg_class c ON c.oid=i.indrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum = ANY(i.indkey)
WHERE n.nspname = p_schema
AND c.relname = p_table
AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)
LIMIT 1;
IF pk_col IS NULL THEN
RAISE EXCEPTION 'No PRIMARY KEY found for %', fqtn;
END IF;
IF (
SELECT array_length(i.indkey, 1)
FROM pg_index i
JOIN pg_class c ON c.oid=i.indrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname = p_schema
AND c.relname = p_table
AND i.indisprimary
) <> 1 THEN
RAISE EXCEPTION 'This function supports single-column PRIMARY KEY only: %', fqtn;
END IF;
SELECT format_type(a.atttypid, a.atttypmod)
INTO pk_type
FROM pg_attribute a
JOIN pg_class c ON c.oid=a.attrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname=p_schema AND c.relname=p_table AND a.attname=pk_col;
IF pk_type NOT IN ('bigint','int8') THEN
RAISE EXCEPTION 'PK column % must be BIGINT/int8. Found: %', pk_col, pk_type;
END IF;
SELECT pg_get_indexdef(i.indexrelid)
INTO pk_def
FROM pg_index i
JOIN pg_class c ON c.oid=i.indrelid
JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname=p_schema AND c.relname=p_table AND i.indisprimary;
IF pk_def ILIKE '%HASH%' THEN
RAISE EXCEPTION 'Table appears HASH-sharded; this function is for RANGE PK tables: %', fqtn;
END IF;
-- best-effort session tuning (may be limited by max_worker_processes)
BEGIN
EXECUTE format('SET LOCAL max_parallel_workers = %s', greatest(0, p_parallelism));
EXCEPTION WHEN OTHERS THEN
END;
BEGIN
EXECUTE format('SET LOCAL max_parallel_workers_per_gather = %s', greatest(0, p_parallelism));
EXCEPTION WHEN OTHERS THEN
END;
SELECT string_agg(
'SELECT COUNT(*) AS c FROM ' || fqtn || ' WHERE ' ||
CASE
WHEN (range_start->>pk_col) IS NULL AND (range_end->>pk_col) IS NULL THEN
'TRUE'
WHEN (range_start->>pk_col) IS NULL THEN
format('%I < %s', pk_col, (range_end->>pk_col))
WHEN (range_end->>pk_col) IS NULL THEN
format('%I >= %s', pk_col, (range_start->>pk_col))
ELSE
format('%I >= %s AND %I < %s', pk_col, (range_start->>pk_col), pk_col, (range_end->>pk_col))
END,
E'\nUNION ALL\n'
)
INTO union_sql
FROM yb_tablet_ranges(rel);
IF union_sql IS NULL THEN
RAISE EXCEPTION 'yb_tablet_ranges() returned no ranges for %', fqtn;
END IF;
union_sql := 'SELECT SUM(c)::bigint FROM (' || E'\n' || union_sql || E'\n' || ') counts';
EXECUTE union_sql INTO total;
RETURN total;
END;
$$;
Example call:
SELECT public.yb_range_union_row_count('public', 'test', 10);
Example run:
yugabyte=# \timing on
Timing is on.
yugabyte=# SELECT public.yb_range_union_row_count('public', 'test', 10);
yb_range_union_row_count
--------------------------
100000000
(1 row)
Time: 8578.967 ms (00:08.579)
Final takeaway
For large range-sharded tables, exact row counts can often be accelerated by counting tablet key ranges and summing them.
● Choose Option A (script) as the primary recommendation. It scales better as datasets grow and avoids per-query parallel worker ceilings.
● Offer Option B (UNION ALL) as a strong “SQL-only” alternative, especially when YSQL worker settings can be tuned higher.
Have Fun!
