Faster exact row counts in YugabyteDB using range sharding and tablet-level parallelism

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 ALL of per-range COUNT(*) subqueries and SUM() them in a single statement (works best when YSQL parallel workers are tuned).

YugabyteDB Version Used
The behavior and examples in this tip were validated using YugabyteDB 2025.2 (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).
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_code approach 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

The script: 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!

My wife and I were touring homes in Southern California last week, and this community had a lot of amenities… but yeah, I don’t think I’ll be swimming in this pool. Ever.