Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Partition Tables

A partitioned table is catalog metadata and has no heap of its own. Rows and ordinary indexes live in leaf partitions.

CREATE TABLE events (
  id bigint,
  happened_at date NOT NULL,
  payload text
) PARTITION BY RANGE (happened_at);

CREATE TABLE events_2024 PARTITION OF events
  FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE events_2025 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

CREATE INDEX events_happened_at_idx ON events (happened_at);
partitioned parent (`events`, no relation storage)
  -> partitioned index (`events_happened_at_idx`, no relation storage)
  -> leaf `events_2024` (heap + leaf index)
  -> leaf `events_2025` (heap + leaf index)

Object and Storage

A PostgreSQL object is a logical database entity represented by one or more system-catalog rows.

An ObjectAddress identifies an object by (classId, objectId, objectSubId).
/* src/include/catalog/objectaddress.h */
typedef struct ObjectAddress
{
    Oid   classId;      /* Class Id from pg_class */
    Oid   objectId;     /* OID of the object */
    int32 objectSubId;  /* Subitem within object (eg column), or 0 */
} ObjectAddress;

classId is the pg_class OID of the catalog that defines the object. Common defining catalogs are:

  • pg_class: relations such as tables, indexes, views, and sequences
  • pg_type: data types
  • pg_proc: functions and procedures
  • pg_namespace: schemas

objectId identifies the object within that catalog. objectSubId is zero for a complete object and identifies a subobject, such as a table column, when nonzero.

(pg_class's OID,     table OID,    0) -> table
(pg_proc's OID,      function OID, 0) -> function
(pg_namespace's OID, schema OID,   0) -> schema
(pg_class's OID,     table OID,    2) -> second column of the table

Relation storage is the set of physical relation files managed by PostgreSQL’s storage manager (smgr). Only some relation kinds in pg_class own relation storage.

Relation objectpg_class row and OIDOwn relation storage
Ordinary tableyesyes
Leaf partitionyesyes
Partitioned tableyesno
Ordinary indexyesyes
Partitioned indexyesno
Viewyesno
pg_class.relfilenode identifies the relation's current physical relation files.
/* src/include/catalog/pg_class.h */
/* identifier of physical storage file */
/* relfilenode == 0 means it is a "mapped" relation, see relmapper.c */
Oid relfilenode BKI_DEFAULT(0);

A relfilenumber (RelFileNumber, historically called relfilenode) is the file-number component of a physical relation locator. It is not the relation’s logical OID.

A physical relation locator contains a tablespace OID, database OID, and relfilenumber.
relation OID
  -> pg_class row / relcache entry
  -> (tablespace OID, database OID, relfilenumber)
  -> physical relation files

A new ordinary relation commonly starts with a relfilenumber equal to its OID. The values can diverge after a relation rewrite. The OID remains the logical identity while PostgreSQL replaces the relation storage. A relfilenode value of zero identifies a mapped relation; PostgreSQL gets its relfilenumber from the relation mapper instead of pg_class.

A relation can use several physical files:

12345       main fork
12345_fsm   free-space map
12345_vm    visibility map
12345_init  initialization fork for an unlogged relation
12345.1     a later segment after the main fork grows large

A table’s TOAST table and indexes are separate relation objects. Each has its own OID and, when applicable, its own relfilenumber and files.

heap_create() disables create_storage for relation kinds without relation storage.
/* src/backend/catalog/heap.c:heap_create */
if (!RELKIND_HAS_STORAGE(relkind))
    create_storage = false;

A partitioned table has no relation storage. Its OID identifies the hierarchy root and its partitioning metadata. Heap and index files belong to leaf partitions. DML and scans must therefore select a leaf.

DDL

CREATE TABLE grammar (`gram.y`)
  -> ProcessUtilitySlow (`utility.c`)
  -> transformCreateStmt (`parse_utilcmd.c`)
  -> DefineRelation (`tablecmds.c`)
  -> heap_create_with_catalog (`heap.c`)
  -> partition-specific catalog updates

CREATE TABLE accepts optional PARTITION BY and PARTITION OF clauses. In CreateStmt:

  • PARTITION BY sets partspec.
  • PARTITION OF adds the parent to inhRelations and sets partbound.
  • A sub-partitioned partition sets both partbound and partspec.
ProcessUtilitySlow() transforms and executes CREATE TABLE and its generated subcommands.
/* src/backend/tcop/utility.c:ProcessUtilitySlow */
stmts = transformCreateStmt((CreateStmt *) parsetree, queryString);

while (stmts != NIL)
{
    Node *stmt = (Node *) linitial(stmts);
    /* ... */
    if (IsA(stmt, CreateStmt))
    {
        CreateStmt *cstmt = (CreateStmt *) stmt;

        address = DefineRelation(cstmt,
                                 RELKIND_RELATION,
                                 InvalidOid, NULL,
                                 queryString);
        /* ... */
        NewRelationCreateToastTable(address.objectId, toast_options);
    }
    else
        ProcessUtility(/* generated subsidiary command */);
}

ProcessUtilitySlow() passes RELKIND_RELATION. DefineRelation() changes it after detecting partspec. PostgreSQL transforms partition key and bound expressions after it creates and opens the relation.

Creating the partitioned parent

DefineRelation
  -> relkind = RELKIND_PARTITIONED_TABLE
  -> heap_create_with_catalog
       -> common pg_class / pg_attribute / pg_type / dependency rows
       -> no physical relation file
  -> ComputePartitionAttrs
  -> StorePartitionKey
       -> pg_partitioned_table
       -> dependencies for key columns, opclasses, collations, expressions
DefineRelation() marks a statement with partspec as RELKIND_PARTITIONED_TABLE.
/* src/backend/commands/tablecmds.c:DefineRelation */
if (stmt->partspec != NULL)
{
    if (relkind != RELKIND_RELATION)
        elog(ERROR, "unexpected relkind: %d", (int) relkind);

    relkind = RELKIND_PARTITIONED_TABLE;
    partitioned = true;
}
else
    partitioned = false;

heap_create_with_catalog() then runs the common relation-creation path. It creates pg_class, pg_attribute, and pg_type rows, plus any dependency, default, and constraint rows. The partition path differs in storage and partition-specific catalogs.

A partitioned parent has relkind = 'p'. This kind is absent from RELKIND_HAS_STORAGE and RELKIND_HAS_TABLE_AM.
/* src/include/catalog/pg_class.h */
#define RELKIND_PARTITIONED_TABLE 'p'

#define RELKIND_HAS_STORAGE(relkind) \
    ((relkind) == RELKIND_RELATION || \
     (relkind) == RELKIND_INDEX || \
     /* ... */)

#define RELKIND_HAS_PARTITIONS(relkind) \
    ((relkind) == RELKIND_PARTITIONED_TABLE || \
     (relkind) == RELKIND_PARTITIONED_INDEX)

#define RELKIND_HAS_TABLE_AM(relkind) \
    ((relkind) == RELKIND_RELATION || \
     /* ... */)
heap_create() suppresses file creation for the partitioned parent.
/* src/backend/catalog/heap.c:heap_create */
if (!RELKIND_HAS_STORAGE(relkind))
    create_storage = false;
needs_toast_table() rejects partitioned tables.
/* src/backend/catalog/toasting.c:needs_toast_table */
if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
    return false;

The parent has no heap file, table-AM scan target, or TOAST table. Its tablespace and access-method settings provide defaults for partitions.

pg_partitioned_table stores one row for each partitioned table. It defines the table’s partition key. It does not store child bounds; each child bound is stored in pg_class.relpartbound.

FormData_pg_partitioned_table defines the pg_partitioned_table catalog schema.
/* src/include/catalog/pg_partitioned_table.h:FormData_pg_partitioned_table */
CATALOG(pg_partitioned_table,3350,PartitionedRelationId)
{
    Oid         partrelid BKI_LOOKUP(pg_class);
    char        partstrat;
    int16       partnatts;
    Oid         partdefid BKI_LOOKUP_OPT(pg_class);
    int2vector  partattrs BKI_FORCE_NOT_NULL;

#ifdef CATALOG_VARLEN
    oidvector   partclass BKI_LOOKUP(pg_opclass) BKI_FORCE_NOT_NULL;
    oidvector   partcollation BKI_LOOKUP_OPT(pg_collation) BKI_FORCE_NOT_NULL;
    pg_node_tree partexprs;
#endif
} FormData_pg_partitioned_table;

The columns have these roles:

  • partrelid: OID of the partitioned relation; also the catalog’s primary key
  • partstrat: strategy (h for hash, l for list, or r for range)
  • partnatts: number of partition key fields
  • partdefid: OID of the default partition, or zero
  • partattrs: parent attribute number for each key field; zero denotes an expression
  • partclass: operator class for each key field
  • partcollation: collation for each key field
  • partexprs: serialized expression trees for zero entries in partattrs
DefineRelation() transforms and stores the partition key.
/* src/backend/commands/tablecmds.c:DefineRelation */
stmt->partspec = transformPartitionSpec(rel, stmt->partspec);

ComputePartitionAttrs(pstate, rel, stmt->partspec->partParams,
                      partattrs, &partexprs, partopclass,
                      partcollation, stmt->partspec->strategy);

StorePartitionKey(rel, stmt->partspec->strategy, partnatts, partattrs,
                  partexprs, partopclass, partcollation);
StorePartitionKey() inserts the partition key into pg_partitioned_table.
/* src/backend/catalog/heap.c:StorePartitionKey */
values[Anum_pg_partitioned_table_partrelid - 1] =
    ObjectIdGetDatum(RelationGetRelid(rel));
values[Anum_pg_partitioned_table_partstrat - 1] = CharGetDatum(strategy);
values[Anum_pg_partitioned_table_partnatts - 1] = Int16GetDatum(partnatts);
values[Anum_pg_partitioned_table_partdefid - 1] =
    ObjectIdGetDatum(InvalidOid);
values[Anum_pg_partitioned_table_partattrs - 1] = PointerGetDatum(partattrs_vec);
values[Anum_pg_partitioned_table_partclass - 1] = PointerGetDatum(partopclass_vec);
values[Anum_pg_partitioned_table_partcollation - 1] =
    PointerGetDatum(partcollation_vec);
values[Anum_pg_partitioned_table_partexprs - 1] = partexprDatum;

CatalogTupleInsert(pg_partitioned_table, tuple);

Creating a leaf with PARTITION OF

DefineRelation
  -> MergeAttributes from parent
  -> heap_create_with_catalog
       -> ordinary relation catalogs
       -> leaf relation storage
  -> transformPartitionBound
  -> check_new_partition_bound
  -> StorePartitionBound
       -> pg_class.relpartbound
       -> pg_class.relispartition = true
  -> StoreCatalogInheritance
       -> pg_inherits
       -> child-to-parent dependency

A leaf uses RELKIND_RELATION unless it is also partitioned. In this example, events_2024 owns heap relation storage and has pg_class.relispartition = true.

DefineRelation() validates and stores the leaf's partition bound.
/* src/backend/commands/tablecmds.c:DefineRelation */
bound = transformPartitionBound(pstate, parent, stmt->partbound);
check_new_partition_bound(relname, parent, bound, pstate);

/* Update the pg_class entry. */
StorePartitionBound(rel, parent, bound);

/* Store inheritance information for new rel. */
StoreCatalogInheritance(relationId, inheritOids, stmt->partbound != NULL);
StorePartitionBound() stores the serialized bound in pg_class.relpartbound and marks the relation as a partition.
/* src/backend/catalog/heap.c:StorePartitionBound */
new_val[Anum_pg_class_relpartbound - 1] =
    CStringGetTextDatum(nodeToString(bound));
new_repl[Anum_pg_class_relpartbound - 1] = true;
newtuple = heap_modify_tuple(tuple, RelationGetDescr(classRel),
                             new_val, new_null, new_repl);
((Form_pg_class) GETSTRUCT(newtuple))->relispartition = true;

CatalogTupleUpdate(classRel, &newtuple->t_self, newtuple);
StoreSingleInheritance() stores the parent-child edge in pg_inherits.
/* src/backend/catalog/pg_inherits.c:StoreSingleInheritance */
values[Anum_pg_inherits_inhrelid - 1] = ObjectIdGetDatum(relationId);
values[Anum_pg_inherits_inhparent - 1] = ObjectIdGetDatum(parentOid);
values[Anum_pg_inherits_inhseqno - 1] = Int32GetDatum(seqNumber);
values[Anum_pg_inherits_inhdetachpending - 1] = BoolGetDatum(false);

CatalogTupleInsert(inhRelation, tuple);
A partition uses an AUTO dependency; ordinary inheritance uses a NORMAL dependency.
/* src/backend/commands/tablecmds.c */
#define child_dependency_type(child_is_partition) \
    ((child_is_partition) ? DEPENDENCY_AUTO : DEPENDENCY_NORMAL)

Ordinary INHERITS also uses pg_inherits, but it creates a NORMAL dependency and has no partition metadata. For a default partition, StorePartitionBound() also stores the leaf OID in pg_partitioned_table.partdefid.

The catalogs distinguish each relation type as follows:

Objectpg_classpg_partitioned_tablepg_inheritsRelation storage
Normal heaprelkind = 'r', relispartition = falsenonenone normallyyes
Partitioned parentrelkind = 'p'partition strategy and keyrow only if it is itself a childno
Leaf partitionusually relkind = 'r', relispartition = true, bound in relpartboundnonechild-to-parent rowyes
Sub-partitioned childrelkind = 'p', relispartition = true, boundits own keychild-to-parent rowno

A parent index follows the same model. It has relkind = 'I' and no relation storage. Its leaf indexes are ordinary index relations.

Leaf column definitions

PARTITION OF
  -> MergeAttributes(parent)
  -> BuildDescForRelation
  -> heap_create_with_catalog
       -> independent pg_class row
       -> independent pg_attribute rows
       -> independent pg_type row

ATTACH PARTITION
  -> MergeAttributesIntoExisting
       -> match columns by name
       -> validate type, collation, and constraints

The parent and each leaf have separate column definitions. The parent defines the logical row type. Each leaf has its own TupleDesc and pg_attribute rows.

DefineRelation() builds a new partition's descriptor from its parent before creating the leaf relation.
/* src/backend/commands/tablecmds.c:DefineRelation */
stmt->tableElts =
    MergeAttributes(stmt->tableElts, inheritOids,
                    stmt->relation->relpersistence,
                    stmt->partbound != NULL,
                    &old_constraints);

descriptor = BuildDescForRelation(stmt->tableElts);

relationId = heap_create_with_catalog(/* ... */, descriptor, /* ... */);

An attached table must expose the same logical columns, but its physical attribute numbers can differ.

MergeAttributesIntoExisting() matches an attached table's columns by name.
/* src/backend/commands/tablecmds.c:MergeAttributesIntoExisting */
for (AttrNumber parent_attno = 1;
     parent_attno <= parent_desc->natts;
     parent_attno++)
{
    Form_pg_attribute parent_att =
        TupleDescAttr(parent_desc, parent_attno - 1);
    char *parent_attname = NameStr(parent_att->attname);

    tuple = SearchSysCacheCopyAttName(RelationGetRelid(child_rel),
                                      parent_attname);

    /* Validate type, typmod, collation, NOT NULL, and generation state. */
}

Tuple-conversion maps translate between parent and leaf attribute numbers. Structural changes on the parent recurse through the hierarchy and update each leaf’s catalog rows. Leaves can still have distinct defaults, indexes, additional constraints, statistics, and storage settings.

DML

INSERT: route one row to a leaf

ExecModifyTable
  -> ExecInsert
       -> ExecPrepareTupleRouting
            -> ExecFindPartition
       -> table_tuple_insert(leaf relation)
       -> ExecInsertIndexTuples(leaf indexes)

ExecModifyTable() reads one tuple from its subplan and calls ExecInsert(). A partitioned target has a PartitionTupleRouting structure.

ExecInsert() replaces the root ResultRelInfo with the selected leaf before writing the row.
/* src/backend/executor/nodeModifyTable.c:ExecInsert */
if (proute)
{
    ResultRelInfo *partRelInfo;

    slot = ExecPrepareTupleRouting(mtstate, estate, proute,
                                   resultRelInfo, slot,
                                   &partRelInfo);
    resultRelInfo = partRelInfo;
}

resultRelationDesc = resultRelInfo->ri_RelationDesc;
ExecFindPartition() evaluates the partition key and finds a matching child at each partitioning level.
/* src/backend/executor/execPartition.c:ExecFindPartition */
while (dispatch != NULL)
{
    /* ... */
    rel = dispatch->reldesc;
    partdesc = dispatch->partdesc;

    ecxt->ecxt_scantuple = slot;
    FormPartitionKeyDatum(dispatch, slot, estate, values, isnull);

    if (partdesc->nparts == 0 ||
        (partidx = get_partition_for_tuple(dispatch, values, isnull)) < 0)
        ereport(ERROR,
                (errmsg("no partition of relation \"%s\" found for row",
                        RelationGetRelationName(rel))));

    is_leaf = partdesc->is_leaf[partidx];
    /* Return the leaf, or descend into a sub-partitioned child. */
}
ExecPrepareTupleRouting() converts the root tuple to the selected leaf's physical attribute layout when needed.
/* src/backend/executor/nodeModifyTable.c:ExecPrepareTupleRouting */
partrel = ExecFindPartition(mtstate, targetRelInfo, proute, slot, estate);

map = ExecGetRootToChildMap(partrel, estate);
if (map != NULL)
{
    TupleTableSlot *new_slot = partrel->ri_PartitionTupleSlot;

    slot = execute_attr_map_slot(map->attrMap, slot, new_slot);
}

PostgreSQL initializes each leaf’s ResultRelInfo on first use. A single-row insert does not open every partition. After routing, table_tuple_insert() and ExecInsertIndexTuples() enter the normal leaf heap and index paths.

An insert through the partitioned parent has additional cost:

  • Evaluate and route each row.
  • Search again at each sub-partitioning level.
  • Convert the tuple when the leaf layout differs.

When traffic is distributed across leaves, partitioning may:

  • Update smaller leaf indexes.
  • Improve cache and data locality.
  • Separate hot heap and index pages across leaves.

Multiple rows and batch insertion

multi-row SQL INSERT
  -> ExecModifyTable
       -> route and insert one row at a time

COPY FROM
  -> ExecFindPartition for each row
  -> buffer rows by leaf
  -> table_multi_insert for each leaf buffer
ExecModifyTable() processes a multi-row SQL insert one row at a time.
/* src/backend/executor/nodeModifyTable.c:ExecModifyTable */
for (;;)
{
    /* fetch the next row from the subplan */
    context.planSlot = ExecProcNode(subplanstate);
    if (TupIsNull(context.planSlot))
        break;

    /* ... */
    slot = ExecInsert(&context, resultRelInfo, slot,
                      node->canSetTag, NULL, NULL);
}

Each row may route to a different leaf and reaches table_tuple_insert() separately. PostgreSQL reuses routing state and initialized leaf state. ExecBatchInsert() handles FDWs that implement ExecForeignBatchInsert; it does not batch local heap inserts.

COPY FROM routes each row and maintains a buffer for each destination leaf.
/* src/backend/commands/copyfrom.c:CopyFrom */
resultRelInfo = ExecFindPartition(mtstate, target_resultRelInfo,
                                  proute, myslot, estate);

/* ... determine whether this leaf supports multi-insert ... */
if (leafpart_use_multi_insert)
{
    if (resultRelInfo->ri_CopyMultiInsertBuffer == NULL)
        CopyMultiInsertInfoSetupBuffer(&multiInsertInfo, resultRelInfo);
}
CopyMultiInsertBufferFlush() writes one leaf buffer with a single table-AM call.
/* src/backend/commands/copyfrom.c:CopyMultiInsertBufferFlush */
table_multi_insert(resultRelInfo->ri_RelationDesc,
                   slots,
                   nused,
                   mycid,
                   ti_options,
                   buffer->bistate);

Triggers, volatile expressions, and an FDW without batch support can force COPY to use single-row insertion:

multi-row SQL INSERT: route row -> insert row -> repeat
COPY FROM:            route rows -> group by leaf -> table_multi_insert per leaf

DELETE

partition expansion and pruning
  -> leaf scan
       -> leaf ResultRelInfo + ctid
  -> ExecDelete
       -> ExecDeleteAct
       -> table_tuple_delete(leaf relation)

DELETE does not route rows by value. The planner expands and prunes the hierarchy. Each scan row carries the source leaf OID in a junk tableoid column and the row identity in ctid.

ExecModifyTable() uses tableoid to select the leaf's ResultRelInfo.
/* src/backend/executor/nodeModifyTable.c:ExecModifyTable */
if (AttributeNumberIsValid(node->mt_resultOidAttno))
{
    datum = ExecGetJunkAttribute(context.planSlot,
                                 node->mt_resultOidAttno,
                                 &isNull);
    resultoid = DatumGetObjectId(datum);

    if (resultoid != node->mt_lastResultOid)
        resultRelInfo = ExecLookupResultRelByOid(node, resultoid,
                                                 false, true);
}

ExecDelete() then calls table_tuple_delete() on that leaf. Tuple deletion and later index cleanup follow the normal heap path.

UPDATE

leaf scan
  -> ExecUpdate
       -> ExecUpdateAct
            -> partition constraint passes
                 -> table_tuple_update(current leaf)
            -> partition constraint fails
                 -> ExecCrossPartitionUpdate
                      -> ExecDelete(old leaf)
                      -> ExecInsert(root)
                           -> route to new leaf

An update starts on the leaf selected by the scan:

  1. If the new row satisfies the leaf’s partition constraint, table_tuple_update() updates the leaf and its indexes.
  2. If the new row violates the constraint, PostgreSQL deletes it from the old leaf and routes an insert to the new leaf.
ExecUpdateAct() chooses an in-leaf update or a cross-partition update.
/* src/backend/executor/nodeModifyTable.c:ExecUpdateAct */
partition_constraint_failed =
    resultRelationDesc->rd_rel->relispartition &&
    !ExecPartitionCheck(resultRelInfo, slot, estate, false);

if (partition_constraint_failed)
{
    /* DELETE from source, then route INSERT from the root. */
    if (ExecCrossPartitionUpdate(context, resultRelInfo,
                                 tupleid, oldtuple, slot,
                                 canSetTag, updateCxt,
                                 &result, &retry_slot,
                                 &inserted_tuple, &insert_destrel))
    {
        updateCxt->crossPartUpdate = true;
        return TM_Ok;
    }
    /* concurrent-update retry omitted */
}

/* If the row remains in this leaf, perform an ordinary update. */
result = table_tuple_update(resultRelationDesc, tupleid, slot,
                            estate->es_output_cid,
                            estate->es_snapshot,
                            estate->es_crosscheck_snapshot,
                            true, &context->tmfd,
                            &updateCxt->lockmode,
                            &updateCxt->updateIndexes);
ExecCrossPartitionUpdate() deletes from the old leaf and inserts through the root.
/* src/backend/executor/nodeModifyTable.c:ExecCrossPartitionUpdate */
ExecDelete(context, resultRelInfo,
           tupleid, oldtuple,
           false,  /* processReturning */
           true,   /* changingPart */
           false,  /* canSetTag */
           tmresult, &tuple_deleted, &epqslot);

/* Convert the old leaf layout back to the root layout if necessary. */
if (tupconv_map != NULL)
    slot = execute_attr_map_slot(tupconv_map->attrMap,
                                 slot, mtstate->mt_root_tuple_slot);

/* ExecInsert starts routing at the root and finds the new leaf. */
context->cpUpdateReturningSlot =
    ExecInsert(context, mtstate->rootResultRelInfo, slot, canSetTag,
               inserted_tuple, insert_destrel);

The delete and insert form one transactional SQL update, but modify two leaf heaps and their indexes. An update that directly targets a leaf cannot move the row outside that leaf; PostgreSQL reports a partition-constraint violation.

DQL

The shared planning path: expand, prune, append

expand_partitioned_rtentry
  -> PartitionDirectoryLookup
  -> prune_append_rel_partitions
       -> gen_partprune_steps
       -> get_matching_partitions
  -> build child RelOptInfo objects
  -> set_append_rel_pathlist
       -> choose an access path for each leaf
       -> build Append paths
  -> make_partition_pruneinfo for runtime pruning
  -> ExecInitAppend
       -> ExecInitPartitionPruning
  -> ExecAppend
expand_partitioned_rtentry() prunes the hierarchy and creates planner relations for surviving children.
/* src/backend/optimizer/util/inherit.c:expand_partitioned_rtentry */
partdesc = PartitionDirectoryLookup(root->glob->partition_directory,
                                    parentrel);

relinfo->live_parts = live_parts =
    prune_append_rel_partitions(relinfo);

/* ... */
while ((i = bms_next_member(live_parts, i)) >= 0)
{
    Oid childOID = partdesc->oids[i];
    Relation childrel = try_table_open(childOID, lockmode);

    expand_single_inheritance_child(root, parentrte, parentRTindex,
                                    parentrel, top_parentrc, childrel,
                                    &childrte, &childRTindex);
    childrelinfo = build_simple_rel(root, childRTindex, relinfo);
    /* Recurse if this child is also partitioned. */
}
set_append_rel_pathlist() builds access paths for each surviving child and combines them into append paths.
/* src/backend/optimizer/path/allpaths.c:set_append_rel_pathlist */
foreach(l, root->append_rel_list)
{
    /* ... locate childRTE and childrel ... */
    set_rel_pathlist(root, childrel, childRTindex, childRTE);

    if (!IS_DUMMY_REL(childrel))
        live_childrels = lappend(live_childrels, childrel);
}

add_paths_to_append_rel(root, rel, live_childrels);
prune_append_rel_partitions() performs plan-time pruning.
/* src/backend/partitioning/partprune.c:prune_append_rel_partitions */
if (!enable_partition_pruning || clauses == NIL)
    return bms_add_range(NULL, 0, rel->nparts - 1);

gen_partprune_steps(rel, clauses, PARTTARGET_PLANNER, &gcontext);
if (gcontext.contradictory)
    return NULL;

return get_matching_partitions(&context, gcontext.steps);

Append concatenates child results without sorting or duplicate removal. It is similar to UNION ALL, but also represents partition and inheritance scans. MergeAppend merges ordered child results. make_partition_pruneinfo() records pruning steps for values available only during executor startup or rescan.

ExecInitAppend() initializes only the matching subplans.
/* src/backend/executor/nodeAppend.c:ExecInitAppend */
if (node->part_prune_info != NULL)
{
    prunestate = ExecInitPartitionPruning(&appendstate->ps,
                                          list_length(node->appendplans),
                                          node->part_prune_info,
                                          &validsubplans);
    appendstate->as_prune_state = prunestate;
    nplans = bms_num_members(validsubplans);
}
ExecAppend() reads tuples from each surviving leaf subplan.
/* src/backend/executor/nodeAppend.c:ExecAppend */
subnode = node->appendplans[node->as_whichplan];
result = ExecProcNode(subnode);

if (!TupIsNull(result))
    return result;

/* Current leaf is exhausted; choose the next surviving subplan. */
if (!node->choose_next_subplan(node) && node->as_nasyncremain == 0)
    return ExecClearTuple(node->ps.ps_ResultTupleSlot);

For a parameterized plan, ExecFindMatchingSubPlans() runs again when a PARAM_EXEC value changes. The same expansion, pruning, and append path supports table and index scans. PostgreSQL chooses a scan method for each leaf, so one Append can contain both sequential and index scans.

Table scan

surviving leaf RelOptInfo
  -> set_rel_pathlist
  -> create_seqscan_plan
       -> scan_relid = leaf RT index
  -> SeqNext
       -> normal table-AM scan

For example:

SELECT * FROM events
WHERE happened_at >= DATE '2025-02-01';

pruning may remove events_2024. A typical shape is:

Append
  -> Seq Scan on events_2025
create_seqscan_plan() creates a scan for the selected leaf relation.
/* src/backend/optimizer/plan/createplan.c:create_seqscan_plan */
Index scan_relid = best_path->parent->relid;

scan_plan = make_seqscan(tlist,
                         scan_clauses,
                         scan_relid);

The partition layer ends after selecting the leaf RT index. SeqNext() then runs the normal table-AM scan.

Index scan

surviving leaf RelOptInfo
  -> create_index_paths for leaf indexes
  -> create_indexscan_plan
       -> baserelid = leaf RT index
       -> indexoid = leaf index OID
  -> IndexNext
       -> normal index scan

An index declared on the parent is a partitioned index. It defines an index hierarchy but is not scanned. The planner considers each leaf index and may produce:

Append
  -> Index Scan using events_2024_happened_at_idx on events_2024
  -> Index Scan using events_2025_happened_at_idx on events_2025
create_indexscan_plan() records the selected leaf table and leaf index in the scan plan.
/* src/backend/optimizer/plan/createplan.c:create_indexscan_plan */
Index baserelid = best_path->path.parent->relid;
IndexOptInfo *indexinfo = best_path->indexinfo;
Oid indexoid = indexinfo->indexoid;

/* ... */
scan_plan = (Scan *) make_indexscan(/* ... */,
                                    baserelid,
                                    indexoid,
                                    /* ... */);

Partitioning determines which leaf scan plans exist and execute. IndexNext() and the index AM then run an ordinary scan on the selected leaf index.