← You Don't Need MongoDB

Chapter 9: Twenty-One Methods

The Waiter of Gold Lapel · Published Apr 19, 2026 · 10 min

Good evening. You have been patient with me through five chapters of PostgreSQL internals — storage formats, operator vocabularies, index types, validation spectrums, aggregation models. The understanding you now have of the document layer is genuine, permanent, and rather deeper than most developers ever reach for.

Now allow me to show you why you will rarely need to use it directly. One spends the evening in the kitchen so that the guests may dine in the dining room.

Part III introduces the Gold Lapel API — twenty-one methods across seven languages that generate the SQL from Part II using MongoDB-style syntax. The filter syntax you already know. The update operators you already use. The aggregation pipeline you have already built. The difference between your current MongoDB code and these methods is one import statement and a connection string. The database underneath is the one you just spent five chapters learning to trust.

I want to be direct about a design decision that I believe reflects well on both databases. The API mirrors MongoDB's syntax deliberately — not because PostgreSQL needs to pretend to be MongoDB, but because MongoDB's syntax is genuinely well-designed for document operations. The filter language, the update operators, the pipeline stages — these are good abstractions, refined over years of use by millions of developers. Gold Lapel adopts them because they work, and because a developer migrating from MongoDB should not have to learn a new query language in addition to a new database. Familiarity is a form of hospitality, and the best hospitality is the kind that lets the guest feel at home without pretending to be somewhere they are not.

This chapter walks through all twenty-one methods. Appendix B provides the reference table. This chapter provides the experience of using them, building from a first connection to complex queries. If you will follow me through.

Getting Started

The first encounter with the API should feel effortless. If it does not, the API has failed at its primary job.

Initialize
import goldlapel

gl = goldlapel.start("postgres://user:pass@localhost:5432/mydb")

One line. The proxy is spawned, the internal connection is open, the instance is ready. Now — your first document:

First insert
doc = gl.doc_insert("users", {"name": "Alice", "age": 30, "status": "active"})

The first insert to a collection creates the table automatically — _id UUID, data JSONB, created_at TIMESTAMPTZ — plus a GIN index with jsonb_path_ops. No CREATE TABLE. No schema migration. No configuration file. The collection exists because you wrote to it. If that sounds like MongoDB, it should. The onboarding experience that made MongoDB appealing is available here, on PostgreSQL, with ACID transactions and relational joins waiting underneath.

Every method returns the same format, consistent across all twenty-one methods and all seven languages:

Return format
{"_id": "a1b2c3d4-...", "data": {"name": "Alice", "age": 30, "status": "active"}, "created_at": "2026-04-14T..."}

Your first query:

First query
users = gl.doc_find("users", {"status": "active"})

MongoDB-style filter syntax. {"status": "active"} generates WHERE data @> '{"status": "active"}' — the containment query from Chapter 5, served by the GIN index that was created with the collection. The developer writes the filter. The proxy generates the SQL. The index handles the performance. Nobody calls createIndex().

Two methods and you have a working document store. Everything that follows adds capability to this foundation.

CRUD — The Core Nine

These are the methods most applications use every day. If twenty-one sounds like a lot, know that these nine handle the daily work of the vast majority of document-driven applications.

Insert operations:

Insert
# Single document
doc = gl.doc_insert("users", {"name": "Alice", "age": 30})

# Multiple documents — single round-trip
docs = gl.doc_insert_many("users", [
    {"name": "Bob", "age": 25},
    {"name": "Carol", "age": 35}
])

doc_insert_many batches the inserts into a single SQL statement. One thousand documents in one call, one round-trip to the database. The performance difference between inserting documents one at a time and inserting them in a batch is the performance difference between making one thousand trips to the kitchen and placing one order.

Find operations:

Find
# All matching documents
active_users = gl.doc_find("users", {"status": "active"})

# One document (returns the first match or None)
alice = gl.doc_find_one("users", {"name": "Alice"})

# Count matching documents
count = gl.doc_count("users", {"status": "active"})

doc_find returns a list. doc_find_one returns a single document or None. doc_count returns an integer. The filter syntax is identical across all three — the same MongoDB-style filters, the same fifteen operators, covered in full in Chapter 10.

Update operations:

Update
# Update all matching documents
gl.doc_update("users", {"status": "active"}, {"$set": {"verified": True}})

# Update one document
gl.doc_update_one("users", {"name": "Alice"}, {"$inc": {"login_count": 1}})

First argument: the filter (which documents to update). Second argument: the update expression ($set, $inc, $unset, $push, $pull, $addToSet, and others). If you have used MongoDB's update operators, you know these already. Chapter 11 covers the full set.

Delete operations:

Delete
# Delete all matching documents
gl.doc_delete("users", {"status": "suspended"})

# Delete one document
gl.doc_delete_one("users", {"name": "Alice"})

Nine methods: doc_insert, doc_insert_many, doc_find, doc_find_one, doc_count, doc_update, doc_update_one, doc_delete, doc_delete_one. A team could use these nine exclusively and have a fully functional document store — one that generates optimized SQL, creates its own indexes, and runs on the database they already operate. Most teams will not need more. But more is available, and I would like to show you what it looks like.

Queries — Cursor, Distinct, Index, and Atomic Operations

Production applications ask questions that go beyond basic CRUD. Five methods handle the queries that scale and the operations that must not race.

Cursor iteration for large result sets:

Cursor iteration
cursor = gl.doc_find_cursor("logs", filter={"level": "error"}, batch_size=500)
for batch in cursor:
    process(batch)

Server-side cursor. The application never holds the full result set in memory. For a collection with ten million error logs, this is not an optimization — it is a requirement. An application that loads ten million documents into memory is an application that is about to become unavailable.

Distinct values:

Distinct
statuses = gl.doc_distinct("users", "status")
# ["active", "inactive", "suspended"]

# With a filter
active_roles = gl.doc_distinct("users", "role", filter={"status": "active"})

Dot notation works for nested fields: gl.doc_distinct("users", "address.state") extracts distinct values from within nested documents.

Manual index creation:

Manual index
gl.doc_create_index("users", keys={"email": 1}, unique=True)

The proxy creates GIN indexes automatically, but doc_create_index provides manual control for the cases Chapter 6 identified: expression indexes for range queries, unique constraints, partial indexes for filtered subsets. Auto-indexing handles the floor. doc_create_index raises the ceiling.

Atomic find-and-modify:

Atomic find-and-modify
# Find, update, and return the updated document — atomically
updated = gl.doc_find_one_and_update("users",
    {"name": "Alice"},
    {"$inc": {"login_count": 1}},
    return_document="after"
)

# Find and delete — atomically
job = gl.doc_find_one_and_delete("jobs", {"status": "pending"})

doc_find_one_and_update generates UPDATE...RETURNING in a CTE. The find, the update, and the return happen in a single SQL statement. No read-then-write gap. No race condition where two requests find the same pending job and both claim it. This is the kind of operation that, implemented incorrectly, produces the kind of bug that only appears under load and only in production. Implemented as a single atomic statement, the bug cannot exist. The correct architecture is not one that handles race conditions gracefully. It is one that makes them impossible.

Five methods: doc_find_cursor, doc_distinct, doc_create_index, doc_find_one_and_update, doc_find_one_and_delete.

Aggregation, Change Streams, and Operational Methods

The remaining seven methods provide the features that complete MongoDB parity — aggregation, real-time events, document expiry, and collection size limits.

Aggregation:

Aggregation pipeline
results = gl.doc_aggregate("orders", [
    {"$match": {"status": "shipped"}},
    {"$unwind": "$items"},
    {"$group": {"_id": "$items.product", "revenue": {"$sum": "$items.price"}}},
    {"$sort": {"revenue": -1}},
    {"$limit": 10}
])

The same pipeline syntax you saw in Chapter 8 — but instead of writing the SQL yourself, Gold Lapel generates it. The pipeline stages map to the SQL translations from Chapter 8: $matchWHERE, $unwindjsonb_array_elements, $groupGROUP BY. Chapter 12 covers the aggregation API in depth, including the materialized view cache that turns repeated aggregations into lookups and produces the 84x benchmark.

Change streams:

Change streams
gl.doc_watch("users", callback=handle_change)

def handle_change(event):
    # {"operationType": "update", "_id": "...", "fullDocument": {...}}
    print(f"User {event['_id']} was {event['operationType']}d")

# Stop watching
gl.doc_unwatch("users")

Trigger + LISTEN/NOTIFY underneath. The event format matches MongoDB's change stream shape — same field names, same operation types. Your existing change stream handling code needs minimal modification. Chapter 13 covers change streams, TTL, and capped collections in full.

TTL indexes — documents that expire:

TTL indexes
gl.doc_create_ttl_index("sessions", expire_after_seconds=3600)
gl.doc_remove_ttl_index("sessions")

Session documents that clean themselves up after an hour. No cron job. No scheduled task. The database handles its own housekeeping.

Capped collections — collections that maintain a size limit:

Capped collections
gl.doc_create_capped("logs", max_documents=10000)
gl.doc_remove_cap("logs")

The most recent ten thousand log entries. Older documents are removed automatically when new ones arrive. A collection that manages its own size is a collection that does not surprise you with a storage alert at 3 AM.

Seven methods: doc_aggregate, doc_watch, doc_unwatch, doc_create_ttl_index, doc_remove_ttl_index, doc_create_capped, doc_remove_cap.

The Complete Twenty-One

CRUD (9): doc_insert, doc_insert_many, doc_find, doc_find_one, doc_count, doc_update, doc_update_one, doc_delete, doc_delete_one

Queries + Index (5): doc_find_cursor, doc_distinct, doc_create_index, doc_find_one_and_update, doc_find_one_and_delete

Aggregation + Operations (7): doc_aggregate, doc_watch, doc_unwatch, doc_create_ttl_index, doc_remove_ttl_index, doc_create_capped, doc_remove_cap

Twenty-one methods. Each generates the SQL that Part II taught you to read. Each is available across all seven languages. Each behaves identically regardless of which language calls it.

A twenty-second method — doc_create_collection — exists as a manual escape hatch for creating a collection without inserting a document, useful for setting options like unlogged=True for temporary high-throughput collections. It is not counted in the twenty-one because collection creation is implicit: the first doc_insert creates the table automatically. The parity claim is twenty-one methods, and doc_create_collection is a convenience, not a capability.

Cross-Language Consistency

The API is available in seven languages. The method names follow each language's naming convention. Everything else — parameters, behavior, return format — is identical.

Python
# Python
gl.doc_find("users", {"status": "active"})
Node.js
// Node.js
gl.docFind("users", { status: "active" })
Ruby
# Ruby
gl.doc_find("users", { status: "active" })
Java
// Java
gl.docFind("users", Map.of("status", "active"));
PHP
// PHP
$gl->docFind("users", ["status" => "active"]);
Go
// Go
gl.DocFind("users", map[string]any{"status": "active"})
.NET
// .NET
gl.DocFind("users", new { status = "active" });

Python and Ruby use snake_case. JavaScript, Java, and PHP use camelCase. Go and .NET use PascalCase. The convention follows the language. The behavior follows the spec. Same parameters. Same return format. Same test suite adapted per language.

A developer who learns the API in Python can use it in Node.js by adjusting the casing. A team with backend services in three languages uses one set of documentation, one set of concepts, one mental model. The API meets developers where they are. That, I believe, is how tools should work. A tool that asks you to change how you write code in order to use it is a tool that values its own conventions over yours. A tool that adapts to your conventions is a tool that values your time.

Module-Level Functions

The examples throughout this chapter use instance methods — gl.doc_find(...). The Gold Lapel instance manages its own connection. For most applications, this is the right approach.

For advanced use cases, every method is also available as a module-level function that accepts a connection as a parameter:

Module-level function
from goldlapel import doc_find

# Use a framework-managed connection
results = doc_find(connection, "users", {"status": "active"})

When to use module-level functions: the application manages multiple databases, the framework owns the connection lifecycle (Django, Rails, Spring Boot), or the test suite needs explicit control over connections. Chapter 19 covers framework integration across all seven language ecosystems in detail.


Twenty-one methods. Seven languages. The developer experience that closes the gap between PostgreSQL's document capabilities and the API that makes those capabilities feel natural. The SQL from Part II is what these methods generate. The understanding from Part II is what makes using these methods trustworthy rather than magical. Magic impresses. Understanding endures.

The next three chapters go deep into the three pillars of the API. Chapter 10 covers the filter language — fifteen operators that translate MongoDB's query syntax to the containment queries, comparisons, and jsonpath patterns from Chapter 5. Chapter 11 covers write operations — update operators, upsert, and the atomic operations that keep your data consistent under concurrent access. Chapter 12 brings the aggregation story full circle — doc_aggregate, the materialized view cache, and the benchmark that shows what happens when you stop re-executing the same computation on every request.

The methods are introduced. Now, if you will follow me to the next room, let me show you the language they speak.