Follow-up tip: This tip builds on Preserve Regional Index Tablespaces with pg_partman Templates, which demonstrates how a custom pg_partman template can preserve regional tablespaces on newly created child partitions and indexes.
Version scope: This workaround targets YugabyteDB v2024.2, whose YSQL API is based on PostgreSQL 11 and which bundles pg_partman 4.7.4.
The template-table approach works well when each index on the template has a distinct definition.
However, a special case arises when a YugabyteDB deployment uses multiple duplicate covering indexes with the same columns but different regional tablespaces.
The indexes are intentionally identical. Their different tablespaces place index leaders near applications in different regions.
Unfortunately, the pg_partman 4.7.4 template-processing path used by YugabyteDB v2024.2 cannot reliably reproduce this design. It may treat equivalent index definitions as duplicates, and attempts to make the definitions appear different with artificial partial-index predicates can result in invalid generated SQL.
This tip demonstrates a different approach:
- ● Let
pg_partmancreate and maintain the child partitions. - ● Continue using the template for the child-table tablespace.
- ● Keep the duplicate regional indexes off the template.
- ● Detect child partitions that are missing regional indexes.
- ● Generate the required
CREATE INDEX CONCURRENTLYcommands. - ● Execute each command as a top-level YSQL statement.
- ● Schedule the process to run after
pg_partmanmaintenance.
What Are Duplicate Indexes?
YugabyteDB indexes are distributed objects with their own tablets, replicas, and tablet leaders.
In a multi-region cluster, an application may need immediately consistent reads but may be located far from the leader of the base table or its normal index.
Follower reads can reduce latency, but follower reads can return stale data. Duplicate indexes provide another option.
A duplicate-index design creates multiple covering indexes with:
- ● The same index keys
- ● The same sort order
- ● The same included columns
- ● Different index names
- ● Different regional tablespaces
For example:
CREATE INDEX users_central_idx
ON users (name HASH)
INCLUDE (id, city)
TABLESPACE central_tablespace;
CREATE INDEX users_east_idx
ON users (name HASH)
INCLUDE (id, city)
TABLESPACE east_tablespace;
Both indexes contain the same logical data. However, the tablespaces can assign leader preference to different regions.
The YugabyteDB query planner can then favor an index whose leader is near the application issuing the query. Because the index includes all the columns required by the query, YugabyteDB can use an index-only scan without fetching additional columns from the base table in another region.
| Benefit | Tradeoff |
| Immediately consistent local reads | Every write must update each duplicate index |
| Index-only scans can avoid remote base-table reads | Each duplicate index consumes additional storage |
| Applications in several regions can receive low read latency | Index creation and maintenance consume additional resources |
YugabyteDB documents the increased write latency as an intentional tradeoff: every write must update the base table, the duplicate index leaders, and their replicas.
Why the Template Approach Breaks Down
Consider two indexes on a pg_partman template table:
CREATE INDEX activity_template_central_idx
ON audit.activity_audit_logs_template
USING lsm (
(lower(cardholder_username::text), cid, bank_id) HASH,
activity_timestamp DESC
)
TABLESPACE central_tablespace;
CREATE INDEX activity_template_east_idx
ON audit.activity_audit_logs_template
USING lsm (
(lower(cardholder_username::text), cid, bank_id) HASH,
activity_timestamp DESC
)
TABLESPACE east_tablespace;
The indexes have the same logical definition. Their tablespaces are the meaningful difference.
The YugabyteDB v2024.2 implementation of inherit_template_properties() examines template indexes and compares their reconstructed definitions with indexes on the parent and child tables. It also separately reads and appends the template index’s tablespace while generating the child-index DDL.
One attempted workaround is to add always-true predicates:
WHERE (1 = 1)
and:
WHERE (2 = 2)
The predicates make the index definitions appear different, but they expose a clause-order problem.
The bundled function constructs the index definition first and then appends TABLESPACE. For a partial index, the generated statement can therefore resemble:
CREATE INDEX ON audit.activity_audit_logs_p2026_05
USING lsm (
(lower(cardholder_username::text), cid, bank_id) HASH,
activity_timestamp DESC
)
WHERE (1 = 1)
TABLESPACE central_tablespace;
That is invalid. TABLESPACE must appear before WHERE:
CREATE INDEX ON audit.activity_audit_logs_p2026_05
USING lsm (
(lower(cardholder_username::text), cid, bank_id) HASH,
activity_timestamp DESC
)
TABLESPACE central_tablespace
WHERE (1 = 1);
The YugabyteDB CREATE INDEX grammar places TABLESPACE before the optional WHERE predicate.
Do not disguise duplicate indexes with artificial predicates.
Conditions such as
WHERE (1=1)
and
WHERE (2=2)
do not change which rows are stored in the indexes. They also cause the YugabyteDB v2024.2
pg_partman
template code to place the
TABLESPACE
clause after the predicate, resulting in invalid SQL.
The Workaround
Separate partition maintenance from duplicate-index maintenance.
| Component | Responsibility |
| pg_partman | Creates and maintains the time-based child partitions |
| Template table | Supplies the child-table tablespace but contains no duplicate geo-index definitions |
| Custom procedure | Finds empty future partitions and creates any missing regional indexes |
| pg_cron | Schedules recurring partition and duplicate-index maintenance |
| One-time administration | Creates indexes concurrently on populated current and historical partitions |
Demo
This demo creates monthly audit-log partitions and two duplicate covering indexes on every child partition:
- ● One index in
central_tablespace - ● One index in
east_tablespace
The tablespaces are assumed to exist already.
Demo prerequisite: The tablespace creation statements are omitted because their replica_placement configurations depend on the cloud, region, availability-zone, and leader-preference labels used by your YugabyteDB cluster.
Step 1: Enable pg_partman
CREATE SCHEMA IF NOT EXISTS partman;
CREATE EXTENSION IF NOT EXISTS pg_partman
WITH SCHEMA partman;
YugabyteDB supports the native partitioning mode of pg_partman. The trigger-based partman partitioning type is not supported.
Step 2: Confirm the Installed Version
SELECT
extension.extname AS extension_name,
extension.extversion AS installed_version,
namespace.nspname AS extension_schema
FROM pg_extension AS extension
JOIN pg_namespace AS namespace
ON namespace.oid = extension.extnamespace
WHERE extension.extname = 'pg_partman';
Expected result:
.extension_name | installed_version | extension_schema
----------------+-------------------+-----------------
pg_partman | 4.7.4 | partman
You can also use:
\dx
To compare the available and installed versions:
SELECT
name,
default_version,
installed_version
FROM pg_available_extensions
WHERE name = 'pg_partman';
Step 3: Create the Partitioned Parent
CREATE SCHEMA IF NOT EXISTS audit;
DROP TABLE IF EXISTS audit.activity_audit_logs CASCADE;
DROP TABLE IF EXISTS audit.activity_audit_logs_template CASCADE;
CREATE TABLE audit.activity_audit_logs (
activity_timestamp TIMESTAMPTZ NOT NULL,
cardholder_username TEXT NOT NULL,
cid BIGINT NOT NULL,
bank_id BIGINT NOT NULL,
activity_type TEXT,
result_code TEXT,
details JSONB
)
PARTITION BY RANGE (activity_timestamp);
Do not create the duplicate Central and East indexes on the partitioned parent.
They will be created separately on each physical child partition.
Step 4: Create an Index-Free Template
The template can still control the tablespace assigned to newly created child tables.
CREATE TABLE audit.activity_audit_logs_template (
LIKE audit.activity_audit_logs
INCLUDING DEFAULTS
INCLUDING CONSTRAINTS
)
TABLESPACE central_tablespace;
Notice that INCLUDING INDEXES is not used.
The template contains no definitions for the duplicate regional indexes.
The template still has a purpose. On the PostgreSQL 11–based YSQL API, the template can continue supplying the child-table tablespace. Only the duplicate regional covering indexes are removed from the template and managed separately.
The bundled inherit_template_properties() function applies the template table’s tablespace to child tables on PostgreSQL 11 and earlier.
Step 5: Register the Parent with pg_partman
SELECT partman.create_parent(
p_parent_table => 'audit.activity_audit_logs',
p_control => 'activity_timestamp',
p_type => 'native',
p_interval => 'monthly',
p_premake => 2,
p_template_table => 'audit.activity_audit_logs_template'
);
The initial child partitions should be created without the duplicate Central and East indexes.
List them:
SELECT
child_namespace.nspname AS child_schema,
child.relname AS child_partition
FROM pg_inherits AS inheritance
JOIN pg_class AS child
ON child.oid = inheritance.inhrelid
JOIN pg_namespace AS child_namespace
ON child_namespace.oid = child.relnamespace
WHERE inheritance.inhparent =
'audit.activity_audit_logs'::REGCLASS
ORDER BY child.relname;
Step 6: Create the Duplicate-Index Maintenance Procedure
The following procedure:
- ● Finds monthly child partitions.
- ● Processes only future partitions.
- ● Confirms that each partition is empty.
- ● Creates the Central covering index when it is missing.
- ● Creates the East covering index when it is missing.
- ● Skips current, historical, or populated partitions.
CREATE OR REPLACE PROCEDURE audit.create_missing_activity_geo_indexes()
LANGUAGE plpgsql
AS $procedure$
DECLARE
partition_record RECORD;
central_index_name TEXT;
east_index_name TEXT;
partition_has_rows BOOLEAN;
central_tablespace_name CONSTANT TEXT := 'central_tablespace';
east_tablespace_name CONSTANT TEXT := 'east_tablespace';
BEGIN
/*
* Verify that both required tablespaces exist before
* attempting to create any indexes.
*/
IF NOT EXISTS (
SELECT 1
FROM pg_tablespace
WHERE spcname = central_tablespace_name
)
THEN
RAISE EXCEPTION
'Required tablespace "%" does not exist',
central_tablespace_name;
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_tablespace
WHERE spcname = east_tablespace_name
)
THEN
RAISE EXCEPTION
'Required tablespace "%" does not exist',
east_tablespace_name;
END IF;
/*
* Find the monthly child partitions managed by pg_partman.
*/
FOR partition_record IN
SELECT
child_namespace.nspname AS child_schema,
child.relname AS child_table,
to_date(
substring(
child.relname
FROM 'p([0-9]{4}_[0-9]{2})$'
),
'YYYY_MM'
) AS partition_month
FROM pg_inherits AS inheritance
JOIN pg_class AS child
ON child.oid = inheritance.inhrelid
JOIN pg_namespace AS child_namespace
ON child_namespace.oid = child.relnamespace
WHERE inheritance.inhparent =
'audit.activity_audit_logs'::REGCLASS
AND child.relkind = 'r'
ORDER BY child.relname
LOOP
/*
* Process only future monthly partitions.
*
* Current and historical partitions may already contain
* data or receive DML and should be handled separately
* with top-level CREATE INDEX CONCURRENTLY statements.
*/
IF partition_record.partition_month IS NULL
OR partition_record.partition_month <=
date_trunc('month', CURRENT_DATE)::DATE
THEN
CONTINUE;
END IF;
/*
* Verify that the future partition is still empty.
*/
EXECUTE format(
'SELECT EXISTS (
SELECT 1
FROM %I.%I
LIMIT 1
)',
partition_record.child_schema,
partition_record.child_table
)
INTO partition_has_rows;
IF partition_has_rows THEN
RAISE WARNING
'Skipping %.% because the partition contains rows',
partition_record.child_schema,
partition_record.child_table;
CONTINUE;
END IF;
central_index_name :=
partition_record.child_table || '_central_cov_idx';
east_index_name :=
partition_record.child_table || '_east_cov_idx';
/*
* Create the Central duplicate covering index.
*/
IF to_regclass(
format(
'%I.%I',
partition_record.child_schema,
central_index_name
)
) IS NULL
THEN
EXECUTE format(
'CREATE INDEX NONCONCURRENTLY %I
ON %I.%I
USING lsm (
(
lower(cardholder_username::text),
cid,
bank_id
) HASH,
activity_timestamp DESC
)
INCLUDE (
activity_type,
result_code
)
TABLESPACE %I',
central_index_name,
partition_record.child_schema,
partition_record.child_table,
central_tablespace_name
);
RAISE NOTICE
'Created index % on %.% in tablespace %',
central_index_name,
partition_record.child_schema,
partition_record.child_table,
central_tablespace_name;
ELSE
RAISE NOTICE
'Index % already exists on %.%',
central_index_name,
partition_record.child_schema,
partition_record.child_table;
END IF;
/*
* Create the East duplicate covering index.
*/
IF to_regclass(
format(
'%I.%I',
partition_record.child_schema,
east_index_name
)
) IS NULL
THEN
EXECUTE format(
'CREATE INDEX NONCONCURRENTLY %I
ON %I.%I
USING lsm (
(
lower(cardholder_username::text),
cid,
bank_id
) HASH,
activity_timestamp DESC
)
INCLUDE (
activity_type,
result_code
)
TABLESPACE %I',
east_index_name,
partition_record.child_schema,
partition_record.child_table,
east_tablespace_name
);
RAISE NOTICE
'Created index % on %.% in tablespace %',
east_index_name,
partition_record.child_schema,
partition_record.child_table,
east_tablespace_name;
ELSE
RAISE NOTICE
'Index % already exists on %.%',
east_index_name,
partition_record.child_schema,
partition_record.child_table;
END IF;
END LOOP;
END;
$procedure$;
Do not use this procedure for populated partitions. The procedure creates indexes NONCONCURRENTLY because index creation inside a procedure occurs within a transaction.
Nonconcurrent index creation should not be used while DML is occurring. The procedure therefore processes only empty, future partitions that were created in advance by pg_partman.
The regular expression in the procedure expects the standard monthly pg_partman suffix:
_pYYYY_MM
For a daily or weekly partition set, adjust the expression and date conversion to match the corresponding naming convention.
Step 7: Test the Procedure Manually
Run the procedure before scheduling it:
CALL audit.create_missing_activity_geo_indexes();
Run it again:
CALL audit.create_missing_activity_geo_indexes();
The second execution should not create additional indexes because the procedure checks whether each expected index name already exists.
Step 8: Verify the Duplicate Indexes
SELECT
child.relname AS partition_name,
index_relation.relname AS index_name,
COALESCE(
index_tablespace.spcname,
'pg_default'
) AS index_tablespace,
index_metadata.indisvalid AS index_valid,
pg_get_indexdef(index_relation.oid)
AS index_definition
FROM pg_inherits AS inheritance
JOIN pg_class AS child
ON child.oid = inheritance.inhrelid
JOIN pg_index AS index_metadata
ON index_metadata.indrelid = child.oid
JOIN pg_class AS index_relation
ON index_relation.oid = index_metadata.indexrelid
LEFT JOIN pg_tablespace AS index_tablespace
ON index_tablespace.oid = index_relation.reltablespace
WHERE inheritance.inhparent =
'audit.activity_audit_logs'::REGCLASS
AND (
index_relation.relname LIKE '%_central_cov_idx'
OR
index_relation.relname LIKE '%_east_cov_idx'
)
ORDER BY
child.relname,
index_tablespace.spcname;
Example result:
. partition_name | index_name | index_tablespace | index_valid
----------------------------------+---------------------------------------------------+----------------------+-------------
activity_audit_logs_p2026_08 | activity_audit_logs_p2026_08_central_cov_idx | central_tablespace | t
activity_audit_logs_p2026_08 | activity_audit_logs_p2026_08_east_cov_idx | east_tablespace | t
activity_audit_logs_p2026_09 | activity_audit_logs_p2026_09_central_cov_idx | central_tablespace | t
activity_audit_logs_p2026_09 | activity_audit_logs_p2026_09_east_cov_idx | east_tablespace | t
Each future child partition should have:
- ● One valid covering index in
central_tablespace - ● One valid covering index in
east_tablespace
Step 9: Schedule pg_partman Maintenance
When pg_cron is installed in the same database as the partitioned table, schedule pg_partman maintenance with:
SELECT cron.schedule(
'activity-partition-maintenance',
'0 * * * *',
$command$
SELECT partman.run_maintenance(
p_parent_table => 'audit.activity_audit_logs'
)
$command$
);
This runs partition maintenance at the beginning of every hour.
Step 10: Schedule Duplicate-Index Maintenance
Schedule the custom procedure after the pg_partman job:
SELECT cron.schedule(
'activity-duplicate-index-maintenance',
'10 * * * *',
$command$
CALL audit.create_missing_activity_geo_indexes()
$command$
);
This example waits ten minutes before checking for newly created partitions.
| Time | Action |
| Minute 0 | pg_partman creates any required future partitions. |
| Minute 10 | The custom procedure creates the Central and East covering indexes on empty future partitions. |
Choose an appropriate delay: The second job should not begin until partman.run_maintenance() has finished. Increase the delay when partition maintenance can take longer than ten minutes.
Scheduling Jobs in Another Database
When pg_cron is installed in a dedicated cron database, use cron.schedule_in_database().
Run the following from the cron database:
SELECT cron.schedule_in_database(
'activity-partition-maintenance',
'0 * * * *',
$command$
SELECT partman.run_maintenance(
p_parent_table => 'audit.activity_audit_logs'
)
$command$,
'application_db',
'index_admin'
);
Schedule the duplicate indexes:
SELECT cron.schedule_in_database(
'activity-duplicate-index-maintenance',
'10 * * * *',
$command$
CALL audit.create_missing_activity_geo_indexes()
$command$,
'application_db',
'index_admin'
);
Replace:
application_db
with the database containing the partition set.
Step 11: View the Scheduled Jobs
SELECT
jobid,
jobname,
schedule,
database,
username,
active,
command
FROM cron.job
WHERE jobname IN (
'activity-partition-maintenance',
'activity-duplicate-index-maintenance'
)
ORDER BY jobid;
Step 12: Monitor Job Execution
SELECT
jobid,
runid,
database,
username,
status,
return_message,
start_time,
end_time
FROM cron.job_run_details
WHERE jobid IN (
SELECT jobid
FROM cron.job
WHERE jobname IN (
'activity-partition-maintenance',
'activity-duplicate-index-maintenance'
)
)
ORDER BY start_time DESC
LIMIT 20;
Use return_message to identify errors such as:
- ● Missing permissions
- ● Missing tablespaces
- ● Unexpected partition names
- ● Populated future partitions
- ● Existing indexes with conflicting names
Step 13: Find Incomplete Partitions
The following query returns partitions that do not have exactly one valid index in each required tablespace:
WITH child_partitions AS (
SELECT
child.oid AS child_oid,
child.relname AS child_partition
FROM pg_inherits AS inheritance
JOIN pg_class AS child
ON child.oid = inheritance.inhrelid
WHERE inheritance.inhparent =
'audit.activity_audit_logs'::REGCLASS
),
regional_index_counts AS (
SELECT
child.child_partition,
COUNT(*) FILTER (
WHERE tablespace.spcname = 'central_tablespace'
AND index_metadata.indisvalid
AND index_relation.relname LIKE '%_central_cov_idx'
) AS central_index_count,
COUNT(*) FILTER (
WHERE tablespace.spcname = 'east_tablespace'
AND index_metadata.indisvalid
AND index_relation.relname LIKE '%_east_cov_idx'
) AS east_index_count
FROM child_partitions AS child
LEFT JOIN pg_index AS index_metadata
ON index_metadata.indrelid = child.child_oid
LEFT JOIN pg_class AS index_relation
ON index_relation.oid = index_metadata.indexrelid
LEFT JOIN pg_tablespace AS tablespace
ON tablespace.oid = index_relation.reltablespace
GROUP BY child.child_partition
)
SELECT
child_partition,
central_index_count,
east_index_count
FROM regional_index_counts
WHERE central_index_count <> 1
OR east_index_count <> 1
ORDER BY child_partition;
A completely healthy partition set returns no rows.
Existing-but-invalid indexes require manual attention. The procedure creates an index only when the expected name is missing. It does not automatically drop or replace an index that exists with the wrong definition, the wrong tablespace, or an invalid state. Use the verification queries to identify and correct those cases.
Handling Existing Populated Partitions
The recurring procedure intentionally skips current and historical partitions.
Create indexes on populated partitions separately using top-level concurrent statements:
CREATE INDEX CONCURRENTLY activity_audit_logs_p2026_05_central_cov_idx
ON audit.activity_audit_logs_p2026_05
USING lsm (
(
lower(cardholder_username::text),
cid,
bank_id
) HASH,
activity_timestamp DESC
)
INCLUDE (
activity_type,
result_code
)
TABLESPACE central_tablespace;
CREATE INDEX CONCURRENTLY activity_audit_logs_p2026_05_east_cov_idx
ON audit.activity_audit_logs_p2026_05
USING lsm (
(
lower(cardholder_username::text),
cid,
bank_id
) HASH,
activity_timestamp DESC
)
INCLUDE (
activity_type,
result_code
)
TABLESPACE east_tablespace;
Run each CREATE INDEX CONCURRENTLY statement outside a function, procedure, or explicit transaction block.
Backfill historical partitions carefully. Creating two indexes on every populated historical partition can consume significant CPU, memory, disk, and network resources.
Build the historical indexes in controlled batches and monitor the cluster before enabling the recurring pg_cron jobs.
Monitor Concurrent Index Backfills
While a concurrent index is being created, query:
SELECT
table_relation.relname AS table_name,
index_relation.relname AS index_name,
progress.command,
progress.phase,
progress.tuples_total,
progress.tuples_done
FROM pg_stat_progress_create_index AS progress
JOIN pg_class AS table_relation
ON table_relation.oid = progress.relid
JOIN pg_class AS index_relation
ON index_relation.oid = progress.index_relid
ORDER BY
table_relation.relname,
index_relation.relname;
From ysqlsh, refresh the query every second with:
\watch 1
The progress view is local to the YSQL server where the index operation is running, and the row disappears when the index build completes.
Important Operational Considerations
| Consideration | Recommendation |
| Template indexes | Do not place the duplicate regional indexes on the pg_partman template. |
| Artificial predicates | Do not use WHERE (1=1) or WHERE (2=2) to make duplicate indexes appear different. |
| Future partitions | Create and index them before application traffic reaches their date ranges. |
| Populated partitions | Use top-level CREATE INDEX CONCURRENTLY statements rather than the scheduled procedure. |
| Job timing | Leave enough time between the pg_partman job and the duplicate-index job. |
| Existing indexes | Validate the definition, tablespace, and indisvalid state before relying on name-based idempotence. |
| Identifier length | Keep generated index names within PostgreSQL’s 63-byte identifier limit. |
| Write amplification | Measure the write-latency and storage impact before adding duplicate indexes for additional regions. |
Final Takeaway
Let pg_partman manage the partitions—not the duplicate geo-indexes. In YugabyteDB v2024.2, the bundled pg_partman 4.7.4 template path cannot reliably reproduce otherwise identical indexes whose meaningful difference is their regional tablespace.
Keep the duplicate indexes off the template. Use pg_cron to run partition maintenance and then create the Central and East covering indexes on empty future partitions. Handle populated current and historical partitions separately with top-level CREATE INDEX CONCURRENTLY statements.
Resources
| Resource | Description |
| Preserve Regional Index Tablespaces with pg_partman Templates | The original YugabyteDB Tip describing the template-table workaround for PostgreSQL 11–based YSQL releases. |
| YugabyteDB Duplicate Indexes | Explains how duplicate covering indexes provide immediately consistent reads in multiple regions. |
| YugabyteDB pg_partman Documentation | Documents partition creation, configuration, and recurring maintenance. |
| YugabyteDB pg_cron Documentation | Explains how to enable pg_cron, schedule jobs, and review job execution history. |
| YugabyteDB CREATE INDEX | Describes concurrent and nonconcurrent index creation, covering indexes, and tablespace placement. |
| YugabyteDB Online Index Backfill | Explains online index creation and how to monitor index-backfill progress. |
| YugabyteDB v2024.2 Template Inheritance Function | Shows how the bundled pg_partman implementation compares and reconstructs template indexes. |
Have Fun!
