--- title: "TableAmRoutine - your way of integrating a custom storage engine into PostgreSQL" description: "Learn how TableAmRoutine works under the hood and use it to plug your custom storage engine into PostgreSQL without touching the core." author: "Samba Siva" published: 2026-06-13T10:51:46.624Z updated: 2026-11-02T07:23:25.233Z tags: - "PostgreSQL" - "Technical" - "PGSourceCode" url: "https://sambasivareddy.in/blog/tableamroutine-your-way-of-integrating-a-custom-storage-engine-into-postgresql" --- # TableAmRoutine - your way of integrating a custom storage engine into PostgreSQL Ever thought about building a **custom storage engine** for PostgreSQL like columnar, in-memory, compressed without rewriting the planner or executor? That's exactly what `TableAmRoutine` enables. You just implement ~30 callbacks, tell PostgreSQL where to look, and the rest of the engine works as-is. * * * ## Part 1: What is TableAmRoutine? `TableAmRoutine` is the **Table Access Method (AM) API** - a vtable (struct of function pointers) that defines the _complete interface between the PostgreSQL executor/planner and a storage engine_. This is a core extensibility feature that lets change how PostgreSQL store and retrieve the data without changing the planner and executor layer. Every table in PostgreSQL has a `pg_am` catalog entry. When the executor needs to do anything with a table be it scan, insert, vacuum. It goes through `rel->rd_tableam` , which points to the registered `TableAmRoutine` for that table. * * * ## Part 2: Some understanding with an Example ```sql CREATE TABLE sample (id int primary key, col1 int); SELECT * FROM pg_am; oid | amname | amhandler | amtype ------+--------+----------------------+-------- 2 | heap | heap_tableam_handler | t 403 | btree | bthandler | i 405 | hash | hashhandler | i 783 | gist | gisthandler | i 2742 | gin | ginhandler | i 4000 | spgist | spghandler | i 3580 | brin | brinhandler | i (7 rows) SELECT relname, relam FROM pg_class WHERE oid = 'sample'::regclass; relname | relam ---------+------- sample | 2 (1 row) ``` Now if you see the example provided, `relam` of "sample" table points to `2` i.e, `heap` in pg\_am. Now when we ran a query on the top of the table "sample", now postgresql calls the handler function i.e `heap_tableam_handler` which returns the `TableAmRoutine` containing all the necessary function definitions can be used to perform the operations like SELECT, DMLs, VACUUM etc.. > **Reference**: `heapam_methods` is the Heap's TableAmRoutine exists at `access/heap/heapam_handler.c` * * * ## Part 3: The Core Design ### TableAmRoutine An API struct for a table AM (Access Methods), which should be allocated in a server-lifetime, typically as a `static const struct` . ```c typedef struct TableAmRoutine { NodeTag type; // must be T_TableAmRoutine // ~30 function pointers grouped by category const TupleTableSlotOps *(*slot_callbacks)(Relation rel); TableScanDesc (*scan_begin)(...); bool (*tuple_insert)(...); // ... } TableAmRoutine; ``` > **Reference**: `tableam.h` Now whatever the custom storage engine we are writing should define the respective function definitions and map to the respective pointers in the `TableAmRoutine`. To explain this further, we are going to use existing Heap Access Methods. ### Heap AM Vtable The heap AM implements its own `TableAmRoutine` vtable as a `static const struct` as explained above in the `heapam_handler.c` ``` static const TableAmRoutine heapam_methods = { .type = T_TableAmRoutine, .slot_callbacks = heapam_slot_callbacks, .scan_begin = heap_beginscan, .tuple_insert = heapam_tuple_insert, .tuple_update = heapam_tuple_update, .relation_vacuum = heap_vacuum_rel, // ... }; ``` ### The Handler Function This is the entry point which PostgreSQL calls to get the particular custom AM's `TableAmRoutine`. In heap case, it is defined in the file `heapam_handler.c` as: ```c Datum heap_tableam_handler(PG_FUNCTION_ARGS) { PG_RETURN_POINTER(&heapam_methods); } ``` If you observe the name of the handler function `heap_tableam_handler` . It is the same name stored in the `pg_am` 's `amhandler` shown in the part 2. ### Register the Access Method ```sql CREATE ACCESS METHOD heap TYPE TABLE HANDLER heap_tableam_handler; ``` This triggers `CreateAccessMethod()` in `amcmds.c`, which: 1\. Resolves `heap_tableam_handler` and validates it returns `TABLE_AM_HANDLEROID` 2\. Inserts one row into `pg_am` with `amtype = 't'` (`t` for table, `i` for index) 3\. Records a dependency: **dropping the handler function also drops the AM** ### Usage ```sql -- New table: CREATE TABLE my_table (id int, data text) USING heap; -- default is heap anyway, used just for example -- Existing table (rewrites data): ALTER TABLE existing_table SET ACCESS METHOD heap; -- Make it the default for the session: SET default_table_access_method = heap; ``` * * * ## Part 4: How PostgreSQL accesses this when querying? Every time PostgreSQL tries to open a Relation/Table, it usually loads all the required information into the struct `Relation` (alias of `RelationData` ) be it the tuple descriptor, table's OID, PK information etc. In those, there are two properties related to access methods i.e, `oid rd_amhandler` the OID of the access method (like heap) using by this table and `TableAmRoutine *rd_tableam` containing the vtable given by the `amhandler` , in our case methods defined in `heapam_methods`. ### Code flow path in case of relation\_open() Now let's see what is the path of the code which populates the access methods when a relation/table is opened using the function `relation_open()` . > Note: The nested `=> { }` style below is simplified pseudocode showing the call chain - see `relation.c` , `relcache.c` for the actual C source. **Entry Point**: relation\_open() in the file `common/relation.c` ```c Relation relation_open(oid relationId) { Relation r; // ..... r = RelationIdGetRelation(relationId); => { Relation rd; // ..... Here looks into cache, if hit return that, else build Relation // Cache Miss: Building from scratch rd = RelationBuildDesc(relationId, true); => { // Here all the necessary information will be populated Relation relation; // ...... else if (RELKIND_HAS_TABLE_AM(relation->rd_rel->relkind) || relation->rd_rel->relkind == RELKIND_SEQUENCE) { // OUR AREA OF INTEREST RelationInitTableAccessMethod(relation); => { // Here, the cache lookup will be done to get // the OID of Access method using by this relation tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(relation->rd_rel->relam)); aform = (Form_pg_am) GETSTRUCT(tuple); relation->rd_amhandler = aform->amhandler; //OID saved here /* * Now we can fetch the table AM's API struct */ InitTableAmRoutine(relation); => { relation->rd_tableam = GetTableAmRoutine(relation->rd_amhandler); => { datum = OidFunctionCall0(amhandler); routine = (TableAmRoutine *) DatumGetPointer(datum); // Some checks will be done here return routine; // THIS IS OUR TABLEAMROUTINE } } } } // ...... return relation; } // ..... return rd; } // ..... return r; } ``` * * * ## Part 5: Callback Groups Below table summarizing the callback pointer functions declared in `TableAmRoutine` | Group | Key Callbacks | Purpose | | --- | --- | --- | | Slot | `slot_callbacks` | What kind of `TupleTableSlot` to use for this AM | | Sequential Scan | `scan_begin`, `scan_end`, `scan_getnextslot` | Full table scans | | TID Range Scan | `scan_set_tidrange`, `scan_getnextslot_tidrange` | Range-restricted scans | | Parallel Scan | `parallelscan_estimate`, `parallelscan_initialize` | Parallel query support | | Index Scan | `index_fetch_begin`, `index_fetch_tuple` | Fetching tuples via index TIDs | | DML | `tuple_insert`, `tuple_update`, `tuple_delete`, `tuple_lock` | Row mutations | | Bulk Insert | `multi_insert`, `finish_bulk_insert` | COPY/bulk load path | | DDL | `relation_set_new_filelocator`, `relation_nontransactional_truncate`, `relation_copy_data` | CREATE TABLE, TRUNCATE, CLUSTER | | Maintenance | `relation_vacuum`, `scan_analyze_next_block/tuple`,`index_build_range_scan` | VACUUM, ANALYZE, CREATE INDEX | | Planner | `relation_estimate_size` | Cost estimation | | Executor | `scan_bitmap_next_tuple`, `scan_sample_next_block/tuple` | Bitmap scans, TABLESAMPLE | * * * ## Part 6: Example - How executor uses it? Let's see the case of Executor, how it uses the access methods. The executor never calls these function pointers directly. Instead it uses `table_*` wrapper functions (`tableam.h`) which dispatch through `rd_tableam` : ```go static inline TableScanDesc table_beginscan(Relation rel, Snapshot snapshot, int nkeys, ScanKeyData *key) { uint32 flags = SO_TYPE_SEQSCAN | SO_ALLOW_STRAT | SO_ALLOW_SYNC | SO_ALLOW_PAGEMODE; return rel->rd_tableam->scan_begin(rel, snapshot, nkeys, key, NULL, flags); // ^^^^^^^^^^^^ vtable dispatch which calls "heap_beginscan" internally } ``` * * * ## Part 7: Why TableAmRoutine Matters 1. **Pluggable storage** - You can write a custom AM (columnar, in-memory, compressed, etc.) without touching the core executor. Just implement the ~30 callbacks and register with `CREATE ACCESS METHOD`. 2. **Separation of concerns** - MVCC visibility, tuple format, physical layout, and vacuum strategy are all encapsulated inside the AM. The executor only sees slots and TIDs. 3. **Real-world uses** - Citus's columnar AM, Zedstore (experimental), and third-party AMs like Orioledb all use this interface. The default `heap` AM (`DEFAULT_TABLE_ACCESS_METHOD = "heap"`, tableam.h:29) is just one implementation. 4. **TOAST delegation** - `relation_needs_toast_table`, `relation_toast_am`, and `relation_fetch_toast_slice` let each AM decide its own large-value storage strategy. * * * ## Closing notes This post covered the interface, the wiring, and how PostgreSQL loads your AM at runtime. What it didn't cover is which callbacks are mandatory versus stubbable, TOAST integration, and the visibility/snapshot contract your AM needs to honour, those deserve their own deep dives. For now, `heapam_handler.c` and the Orioledb source are the best two codebases to have open while you build.