TableCatalog
Multi-index lookup over Azure Table Storage — search any record by any identifier, with crash-safe writes, zero infrastructure, and a fraction of the cost of Cosmos DB.
⚠️ The Problem
Many services need a catalog — a lookup table that maps one identifier to another. Azure Table Storage is extremely cheap, but it only indexes on one key. Here's why that matters.
Imagine a hospital system. Each provider has three identifiers: an Employee ID (EID), a Social Security Number (SSN), and a National Provider Identifier (NPI). Any of the three might arrive in an incoming message. You need to be able to start from any one and retrieve the rest.
| EID | SSN | NPI | First Name | Last Name |
|---|---|---|---|---|
F314 | 123-45-6789 | 1414213562 |
Jia | Faux |
B058 | 111-22-3333 | 3141592653 |
Vals | Bandia |
H969 | 987-65-4321 | 2718281828 |
Peke | Hamis |
The obvious solution is a managed database like Azure Cosmos DB. The problem is cost: Cosmos DB carries a hard minimum monthly fee even at idle, and costs compound as you add more services.
Why not just use Azure Table Storage directly?
Under the hood, Azure Table Storage is not really a table — it is a PartitionKey, a RowKey, and a payload blob. Only the PartitionKey is a true index. Querying by any other field requires a full table scan, which is slow and expensive at scale.
📋 How It Works
TableCatalog sidesteps the single-index limitation by storing one physical row per (identifier, record) pair, encoding the identifier directly into the PartitionKey.
Core idea: one logical record → multiple physical rows
Because Azure Table Storage only indexes on the PartitionKey, the trick is to store the same record multiple times — once per identifier you want to search by. Each copy gets a different PartitionKey, so any identifier becomes a valid lookup path.
The result: Regardless of which identifier you query, you always receive the full record. Three indexed lookups, one Azure table, zero full-table scans.
While this triples raw storage for a three-index catalog, Azure Table Storage costs are roughly an order of magnitude lower than Cosmos DB, making the tradeoff economically favorable for read-heavy or high-volume lookup workloads. See the cost comparison below.
The RowKey: deterministic ordering & range queries
The RowKey is derived from a configured sort field (e.g. a timestamp or last name) plus an 8-character MD5 fingerprint of all indexed values. The fingerprint makes each RowKey unique and deterministic across all copies of the record, enabling idempotent writes and safe re-runs.
// e.g. for a provider with sort field = lastName
RowKey = "faux:ec8246f2"
^^^^ lastName ^^^^^^^^ fingerprint of EID|SSN|NPI
This also enables range queries: you can query any index partition filtered to a RowKey range, such as "all transactions for Location 12345 between Jan 1 and Feb 28".
| PartitionKey | RowKey | EID | SSN | NPI | First | Last |
|---|---|---|---|---|---|---|
| 3_eidf314 | faux:ec8246f2 | F314 | 123-45-6789 | 1414213562 | Jia | Faux |
| 3_ssn123456789 | faux:ec8246f2 | F314 | 123-45-6789 | 1414213562 | Jia | Faux |
| 3_npi1414213562 | faux:ec8246f2 | F314 | 123-45-6789 | 1414213562 | Jia | Faux |
🔑 Key Encoding
Different fields can share the same value (e.g. EID 12345 and Staff ID 12345
for different records). A simple concatenation would cause collisions. TableCatalog solves this by
encoding the length of the field name into the PartitionKey — no delimiter needed.
The formula
PartitionKey = len(fieldName) + "_" + lowercase(fieldName) + lowercase(fieldValue)
Examples:
GetPartitionKey("eid", "F314") → "3_eidf314" (len("eid") = 3)
GetPartitionKey("ssn", "123-45-6789") → "3_ssn123456789" (len("ssn") = 3)
GetPartitionKey("npi", "1414213562") → "3_npi1414213562" (len("npi") = 3)
Why not just use a colon as a separator?
Any fixed delimiter can appear in the data itself, creating ambiguity. For example, if field
"a" has value "b:c", and field "a:b" has value "c",
both produce the same string "a:b:c" — you can't tell them apart.
The length-prefix approach is collision-free because the length tells you exactly where the field name ends and the value begins, with no delimiter required:
🛡️ Consistency: The Write-Ahead Log
Storing one record across multiple partitions means multiple separate writes. Azure Table Storage only guarantees atomicity within a partition — not across them. A crash after the first write but before the last leaves the catalog in a corrupted split-state. TableCatalog solves this with a Write-Ahead Log (WAL).
The split-state problem: If a process crashes after writing the EID row but before writing the SSN and NPI rows, your catalog is inconsistent. Looking up by EID works; looking up by SSN returns nothing (or stale data). This is the classic distributed write problem.
How the WAL fixes this
Before any fan-out write, the full intent is recorded to a dedicated WAL partition. A recovery pass then replays any unprocessed WAL entries. Because all fan-out writes are idempotent (inserts skip rows that already exist; deletes skip rows already gone), recovery can safely re-apply any entry any number of times.
Record intent in the WAL safe
Write {operation: "insert", ...record} to the WAL partition. This is a single atomic write. The catalog is unchanged — we are only recording what we intend to do.
Run recover()
Read all WAL entries newer than the last checkpoint. For each unprocessed entry, apply the fan-out writes (insert or delete one row per index key).
Crash mid-fan-out process dies here
The WAL entry still exists. The checkpoint was not yet advanced. The catalog may be partially written — some index rows exist, others don't.
Next write triggers auto-recovery self-healing
recover() runs again. It replays the same WAL entry. Rows that were already written are silently skipped (idempotent). Missing rows are written. Checkpoint advances. Catalog is now consistent.
Key guarantee: Once all WAL entries have been applied, the catalog is always correct — even if the same entry is applied multiple times by competing processes. The worst-case scenario is a brief window where a record is mid-write, not a permanently corrupted state.
✨ Why TableCatalog?
Multi-Index Queries
Search by any field you define. No full-table scans, no secondary index overhead.
Write-Ahead Log
Crashes mid-write never leave indexes in a split or corrupted state.
Self-Healing Recovery
Automatically detects and completes orphaned WAL entries on the next write.
Zero Infrastructure
No clusters, no provisioned throughput, no minimum monthly fee. You pay only for what you use.
Range Queries
Filter any index partition by an ordered field such as a timestamp or transaction ID.
Portable Pattern
The WAL + fan-out pattern applies equally to AWS DynamoDB or any key-value store.
🛠 Quick Start
Call configure() once at startup, then use insert(),
query(), and delete() anywhere in your application.
insert() returns the committed record for clean chained logic.
import az_table_catalog
# Configure schema once (or via environment variables)
az_table_catalog.configure(
index_keys=["phone", "email"],
row_key="timestamp"
)
# insert() returns the committed record
user = az_table_catalog.insert({
"phone": "555-634-5789",
"email": "jia@example.com",
"timestamp": "2026-02-24T12:00:00Z",
"status": "active"
})
# Query any index; optionally filter to a row-key range
results = az_table_catalog.query(
{"phone": "555-634-5789"},
row_from="2026-01-01"
)
# Multi-filter: results must satisfy all conditions
results = az_table_catalog.query({
"phone": "555-634-5789",
"email": "jia@example.com"
})
# Delete all records matching an identifier
az_table_catalog.delete({"email": "jia@example.com"})
📦 Catalog Module Pattern
For production code, the recommended pattern is to create a dedicated catalog module
that owns the configure() call and exposes a named function for every lookup your
application needs. This keeps all catalog logic in one place and gives callers a clean,
intention-revealing API.
With three index keys there are six pairwise lookups. The example below creates all six.
Note that query() always returns a list, so each function takes the
first element with an empty-list guard.
# provider_catalog.py
# Catalog module for the hospital provider directory.
# Call configure() once at application startup.
# Callers import only this module, never az_table_catalog directly.
import az_table_catalog
def configure():
az_table_catalog.configure(
index_keys="eid,ssn,npi",
row_key="lastname" # sorts multiple results alphabetically
)
# ── EID lookups ────────────────────────────────
def eid_to_ssn(eid: str) -> str | None:
rows = az_table_catalog.query({"eid": eid})
return rows[0]["ssn"] if rows else None
def eid_to_npi(eid: str) -> str | None:
rows = az_table_catalog.query({"eid": eid})
return rows[0]["npi"] if rows else None
# ── SSN lookups ────────────────────────────────
def ssn_to_eid(ssn: str) -> str | None:
rows = az_table_catalog.query({"ssn": ssn})
return rows[0]["eid"] if rows else None
def ssn_to_npi(ssn: str) -> str | None:
rows = az_table_catalog.query({"ssn": ssn})
return rows[0]["npi"] if rows else None
# ── NPI lookups ────────────────────────────────
def npi_to_eid(npi: str) -> str | None:
rows = az_table_catalog.query({"npi": npi})
return rows[0]["eid"] if rows else None
def npi_to_ssn(npi: str) -> str | None:
rows = az_table_catalog.query({"npi": npi})
return rows[0]["ssn"] if rows else None
⚙️ Environment Variables
If you prefer not to call configure() explicitly, the library initializes itself
on first use from the following environment variables:
| Variable | Required | Description |
|---|---|---|
AZURE_STORAGE_CONNECTION_STRING |
required | Azure Storage connection string |
TABLE_CATALOG_NAME |
required | Name of the primary catalog table |
TABLE_CATALOG_INDEX_KEYS |
required | Comma-separated list of indexed fields (e.g. eid,ssn,npi) |
TABLE_CATALOG_ROW_KEY |
required | Field to use as the sort key (RowKey base) |
TABLE_CATALOG_WAL_NAME |
optional | WAL table name (defaults to {TABLE_CATALOG_NAME}_WAL) |
📊 Cost Comparison
TableCatalog is designed for developers who want the query flexibility of an indexed database at the price of raw storage. The table below uses Azure's published pricing at time of writing.
| Scenario | Cosmos DB (monthly) | TableCatalog (monthly) | Annual Savings |
|---|---|---|---|
| Small Apps — idle / low traffic | ~$24.00 (min. fee) | ~$0.10 | ~$280 |
| Medium Apps — 100 GB / 5M ops | ~$100.00 | ~$38.00 | ~$740 |
| High Scale — 1 TB / 50M ops | ~$400.00 | ~$190.00 | ~$2,500+ |
These savings compound as you add more tables and services that share the same lookup pattern.
⏱️ No Cold Start
Some alternatives (like Azure SQL Serverless) save money by pausing the database when idle. This causes a cold start delay — the first user after a break waits 30–60 seconds for the database to wake up. To eliminate cold starts on SQL Serverless, you must switch to Provisioned mode, which costs significantly more.
| Service | Mode | Wake-up Delay | Typical Response |
|---|---|---|---|
| Azure SQL (Serverless) | Auto-paused | 30 – 60 seconds | 10ms – 50ms |
| Cosmos DB (Serverless) | Always-on | 0 seconds | 10ms |
| TableCatalog | Always-on | 0 seconds | 15ms – 60ms |
Because Azure Table Storage is a built-in cloud primitive, it never sleeps. TableCatalog gives you the $0.00 idle cost of a paused database with the instant response of an always-on server.
🧠 Technical Specification (Pseudocode)
The TableCatalog pattern can be implemented in any language that can talk to Azure Table Storage (Go, C#, Node.js, etc.). This section defines the exact logic as language-agnostic pseudocode.
1. Key Generation
FUNCTION GetPartitionKey(fieldName, fieldValue):
// Prefix with field-name length to prevent value collisions
normalizedField = Lowercase(fieldName)
normalizedValue = Lowercase(fieldValue)
RETURN Format("{0}_{1}{2}", Length(normalizedField), normalizedField, normalizedValue)
// e.g. GetPartitionKey("eid", "F314") → "3_eidf314"
FUNCTION GetRowKey(record, rowKeyValue, indexKeys):
// Collect indexed values in alphabetical key order, lowercase
sortedValues = []
FOR EACH key IN Sort(indexKeys):
Append Lowercase(str(record[key])) TO sortedValues
fingerprint = Substring(MD5_Hex(Join(sortedValues, "|")), 0, 8)
RETURN Lowercase(Format("{0}:{1}", rowKeyValue, fingerprint))
// e.g. "faux:ec8246f2"
2. Write-Ahead Log & Checkpoint Pattern
FUNCTION Insert(record):
walRowKey = ISO_Timestamp() + "_" + UUID()
WAL.Create({ PartitionKey: "wal", RowKey: walRowKey, operation: "insert", ...record })
Recover()
FUNCTION Delete(filter):
records = Query(filter)
FOR EACH record IN records:
walRowKey = ISO_Timestamp() + "_" + UUID()
WAL.Create({ PartitionKey: "wal", RowKey: walRowKey, operation: "delete", ...record })
Recover()
FUNCTION Recover(startTime = NULL):
IF startTime IS NULL:
checkpoint = WAL.Get("metadata", "checkpoint")
startTime = checkpoint ? checkpoint.datetime : "1900-01-01"
orphans = WAL.Query("PartitionKey eq 'wal' AND RowKey gt '" + startTime + "'")
FOR EACH entry IN orphans:
IF entry.operation == "insert": ApplyInsert(entry)
IF entry.operation == "delete": ApplyDelete(entry)
WAL.Upsert({ PartitionKey: "metadata", RowKey: "checkpoint", datetime: entry.RowKey })
3. Idempotent Fan-out
FUNCTION ApplyInsert(payload):
rowKey = GetRowKey(payload, payload[ROW_KEY_FIELD], INDEX_KEYS)
FOR EACH fieldName IN INDEX_KEYS:
partitionKey = GetPartitionKey(fieldName, payload[fieldName])
CatalogTable.CreateIfNotExists(partitionKey, rowKey, payload)
// Silently ignore ResourceExistsError
FUNCTION ApplyDelete(payload):
rowKey = GetRowKey(payload, payload[ROW_KEY_FIELD], INDEX_KEYS)
FOR EACH fieldName IN INDEX_KEYS:
partitionKey = GetPartitionKey(fieldName, payload[fieldName])
CatalogTable.DeleteIfExists(partitionKey, rowKey)
// Silently ignore ResourceNotFoundError
4. Range Querying
FUNCTION Query(filter, rowFrom = NULL, rowTo = NULL):
field, value = FirstItem(filter)
pk = GetPartitionKey(field, value)
odata = "PartitionKey eq '" + pk + "'"
IF rowFrom IS NOT NULL:
odata += " AND RowKey ge '" + Lowercase(rowFrom) + ":'"
IF rowTo IS NOT NULL:
odata += " AND RowKey le '" + Lowercase(rowTo) + ":z'"
// ":z" works because "z" sorts after all hex characters (0–9, a–f)
results = CatalogTable.Query(odata)
FOR EACH (field, value) IN RemainingItems(filter):
matches = Query({field: value}, rowFrom, rowTo)
results = Intersect(results, matches)
RETURN results
🚀 Getting Started
Get the file
Copy az_table_catalog.py into your project, or run pip install az-table-catalog.
Install the Azure dependency
pip install azure-data-tables
Set your connection string
Set the AZURE_STORAGE_CONNECTION_STRING environment variable to your Azure Storage account connection string.
Configure the catalog
Set TABLE_CATALOG_NAME, TABLE_CATALOG_INDEX_KEYS, and TABLE_CATALOG_ROW_KEY — or call az_table_catalog.configure() at startup.
Start indexing
Use insert(), query(), and delete(). The WAL and recovery are handled automatically.
⚖️ License & Disclaimer
TableCatalog is released under the MIT License. It was developed to solve real-world distributed indexing challenges and is provided "as-is" with no warranty. Users are encouraged to adapt the implementation for their specific environment and requirements.
TableCatalog is a personal project inspired by work-related architectural challenges. The underlying WAL + fan-out pattern is portable and can be applied to any similar storage system in any language.