Data: PostgreSQL and EF Core - storing, guarding and forgetting records

How PostgreSQL works inside (schemas, indexes, MVCC and xmin, isolation levels, advisory locks, full-text search, JSON), how EF Core works (DbContext, change tracking, filters, interceptors, migrations, ExecuteUpdate, retries), and the techniques built on them: soft delete, tenant isolation, race-safe writes, expand-and-contract migrations, GDPR erasure and the Data Protection key ring.

In short

{{brand}} keeps its records in PostgreSQL, an open-source relational database, and reaches it through EF Core, the object-relational mapper of .NET. Each module owns one schema in a shared database. A few rules are written once and apply to every query and every save. Deleted rows are hidden, rows the caller may not see are hidden, and changes land in an audit trail through an interceptor. When two requests collide, the database itself decides - through unique indexes, row versions, serializable transactions and advisory locks.

After this lesson you can Explain what MVCC and each isolation level guarantee. Design indexes and race-safe writes that survive two simultaneous requests. Use EF Core filters, interceptors and migrations without their classic traps. Change a live schema with expand and contract, and erase a person's data.

Words you need

How PostgreSQL is organised and how MVCC works

A PostgreSQL server holds databases; a database holds schemas; a schema holds tables, indexes and constraints. One connection talks to one database but can read every schema in it. Rows live in 8 kB pages on disk, and indexes point at rows inside those pages.

Three consequences you will meet in real work Because every update writes a new version, xmin is a free row version for optimistic concurrency; a transaction left open for hours keeps dead versions alive and the table grows (bloat); and a row stamped inside a transaction becomes visible only at commit - possibly after rows stamped later. All three come back in this lesson.

Indexes: B-tree, unique and partial

Without an index, PostgreSQL reads every row - a sequential scan. A B-tree index keeps values sorted, so finding one value among a million takes a handful of page reads. A composite index on (workspace_id, name) serves queries on workspace_id alone or on both columns, but it is of little use for name alone. The index is sorted by workspace_id first (the leftmost columns rule), so PostgreSQL would have to scan the whole index.

This is how {{brand}} combines soft delete with unique names: every unique index of a soft-deletable table covers live rows only. One shared model rule adds the condition to each such index, so no developer has to remember it. Without it, deleting a tag named Urgent would block a new Urgent forever.

The planner must be able to prove the condition A partial index serves a query only when the query's own WHERE clause implies the index condition. A lookup by name that does not also say deleted_at is null cannot use tags_name_live. With EF Core's soft-delete filter on every query, the condition is always there - so the index always fits.

Reading a query plan with EXPLAIN

An index only helps if the planner uses it. EXPLAIN shows the plan PostgreSQL chose, and EXPLAIN ANALYZE also runs the statement and reports what really happened. It is the first tool to reach for when a query got slow after a table grew.

EXPLAIN ANALYZE really runs the statement An UPDATE or DELETE under EXPLAIN ANALYZE changes the data. Wrap it in a transaction and finish with rollback. On a tiny table the planner may choose a Seq Scan even when an index exists, because reading a few pages is cheaper - test with realistic row counts.

Transactions and isolation levels

A transaction groups statements so they happen completely or not at all - the A of ACID. The isolation level decides what a transaction sees of others running at the same moment. PostgreSQL has three real levels, and its default is Read Committed.

The whole block may run twice With a retrying execution strategy switched on, EF Core refuses a transaction you open yourself outside the strategy, because it could not replay it after a dropped connection. Hand the whole unit to the strategy instead. The Npgsql provider treats a serialization failure as transient, so the same strategy also replays a block that lost a serializable conflict. Keep the block idempotent: no e-mails, payments or HTTP calls inside, or a retry repeats them.

Row locks and advisory locks

PostgreSQL takes a row lock by itself when you update or delete a row; a second writer of the same row waits until the first commits. You can take one on purpose with SELECT ... FOR UPDATE. An advisory lock is different: it locks a number whose meaning your application decides, such as the nightly clean-up job.

Two typical uses of session advisory locks: a background sweep that must run on one server at a time, and a capacity check followed by a write, so two requests can never both take the last free place.

Turning a name into a lock number Advisory lock keys are 64-bit numbers, so a job name must be hashed. Use a stable hash, such as the first 8 bytes of SHA-256. .NET's string.GetHashCode is randomized per process, so two servers would compute different numbers and both run the job.

Connection pooling

Opening a PostgreSQL connection is expensive: a network handshake, authentication and a separate server process for each connection. The server also has a hard limit, max_connections, 100 by default. Applications therefore keep a pool of open connections and lend them out.

Session advisory locks need a session-mode connection A session advisory lock lives as long as one server connection. In transaction mode the pooler may give your next statement a different connection, so the lock is lost or held by the wrong client. Connect straight to PostgreSQL, or through a session-mode pooler, for work that relies on session state. A slow transaction also holds its connection: pool exhaustion looks like random timeouts.

Full-text search and JSON inside PostgreSQL

Full-text search turns text into a tsvector: a sorted list of word stems with their positions, without stop words such as the and on. A question becomes a tsquery. The @@ operator tests a match, and ts_rank or ts_rank_cd scores it. A GIN index on a stored vector keeps the search fast on millions of rows.

PostgreSQL has two JSON types. json keeps the text exactly as it was sent. jsonb stores a parsed binary form. Its keys are de-duplicated, it can be indexed with GIN, and operators such as ->> (read a value as text) and @> (contains) work inside it.

How EF Core works

EF Core maps C# classes to tables. You write LINQ; EF Core translates it into SQL for the configured provider - Npgsql for PostgreSQL - runs it, and turns the rows back into objects. A DbContext lives for one request, and its change tracker remembers every object it loaded.

The options in the market

What {{brand}} chose, and why

{{brand}} needed one engine that runs the same on a developer's laptop, in CI, as a managed cloud database and inside a customer's own network for the Private edition. PostgreSQL is free to run everywhere. The features this lesson teaches - partial unique indexes, xmin row versions, serializable transactions, advisory locks and full-text ranking - are all built in, and all used.

EF Core was chosen for its global query filters and interceptors. With them, the rules that protect data - hide deleted rows, hide other organizations, audit every change - are written once instead of in every query. Where SQL itself is the point, such as advisory locks, ranking or reading the server's own activity view, {{brand}} sends plain SQL through Npgsql beside EF Core.

For keeping customers apart, {{brand}} uses shared tables with a tenant column and filters written once. A database of their own is an option layered on top, for organizations that need their data in a place they choose.

When another choice would be right A company that already runs and licenses SQL Server should use it - EF Core supports it just as well. Truly schema-less documents may fit MongoDB. A read-heavy reporting service with hand-tuned SQL may prefer Dapper next to EF Core.

Soft delete and global query filters

A soft delete marks a row as deleted with a time instead of removing it. The row can be restored, the audit trail can still name it, and a report about last month stays correct. The price is twofold: every query must skip deleted rows, and unique names must ignore them. Global query filters pay the first price; partial unique indexes pay the second.

A filter may read a property of the DbContext, such as the current organization. EF Core does not bake the value into its cached query: it reads it again for each context instance, so one SQL shape serves every caller with their own parameter. That is why a DbContext is created per request and never shared. Before EF Core 10 an entity had one filter, so conditions were joined with &&; EF Core 10 adds named filters that can be switched off one at a time.

A rule worth following everywhere Never mix a filtered set and IgnoreQueryFilters in one LINQ expression - the leak is silent, and no test that runs as a single tenant will notice it. Load the visible rows first, then read what you need by id in a query of its own.

Interceptors and the audit trail

An interceptor is a class EF Core calls at fixed moments. A save interceptor sees every tracked change just before it is written - the ideal place for rules that no handler may forget. Attach them where every context is registered, so a new module is covered the moment it registers its database.

One transaction, or none Writing the audit row in the same save as the change is what makes the trail trustworthy. An audit written afterwards by a second call is lost whenever the process stops between the two.

Keeping organizations apart (multi-tenancy)

A tenant is one customer's space - a company, or a person's own workspace. Most SaaS products keep many tenants in one database. The danger is a single missed condition: a query without its tenant check shows one company's data to another. The defence is to make the check impossible to forget and to take the tenant only from the verified caller.

When two requests collide

A race condition is a bug that appears only when two requests run at the same moment. The classic is check-then-act: both requests check that a name is free, both see yes, both insert. Code alone cannot close the gap between the check and the write. The database must decide.

xmin is a transaction id, not a counter xmin is a 32-bit transaction id. It wraps around, and vacuum can replace an old value with a frozen marker, so a conflict may be reported although nobody edited the row. Reloading and retrying is always safe. Never sort or compare versions by it.

Commit order is not timestamp order A transaction can stamp a row at 10:00:01 and commit at 10:00:05, after a row stamped 10:00:03 was already visible. A reader that pages through rows by time can skip the late row forever. A reader of a log in time order should stop short of the start of the oldest unfinished write transaction - PostgreSQL shows it in pg_stat_activity - minus a safety margin. Otherwise a late commit can hide behind the cursor.

The transactional outbox

A request often changes data and must tell someone: send an e-mail, update a search index, notify another module. Doing both directly is unsafe. If the process stops between the commit and the call, the change exists and the announcement is lost. The transactional outbox fixes this: write the announcement as a row in the same transaction as the change, and deliver it afterwards. {{brand}} uses this pattern for platform events.

Delivery is at least once A worker can crash after delivering and before marking the row, so the same event may be delivered twice. Make every handler idempotent: handling an event a second time must change nothing.

Paging and backups

Two habits of a database that holds real customers. Page deliberately, because lists grow. And back up in a way you have actually restored, because a backup nobody has tested is a hope.

Rehearse the restore Restore into an empty server on a schedule and run a few queries. Time it. Keep the encryption keys for any encrypted columns with the backup - see the Data Protection section below.

Migrations and expand-and-contract

An EF Core migration is a C# class with an Up method that applies a change and a Down method that undoes it. It is generated by comparing your model with the model snapshot of the last migration. A history table in the database records which migrations ran, so applying them is repeatable.

During a deploy the old version keeps running while the new one starts, and a rollback runs the old code on the new schema. So a migration must never break the version before it. Expand and contract splits every breaking change into safe steps spread over releases.

Many teams start with one baseline migration per context, regenerated on every change, while every database is disposable. Once real data exists, migrations become additive only. A mature team enforces this with a test that fails the build when a migration drops, renames, retypes or adds a required column without a default, with a test that checks each model still matches its last migration, and with a CI job that applies every migration up and down on a real PostgreSQL.

GDPR erasure: forgetting a person for real

The right to erasure (GDPR Article 17) lets a person ask for their personal data to be deleted. A soft delete is not enough - the data is still there. But deleting everything a person ever touched would damage other people's records: a board they commented on, a report with their name in its history. Erasure is therefore a set of techniques, chosen row by row.

Erasure is bookkeeping, not an edit An erased or anonymized row keeps its created and updated times and never shows up as a fresh edit. Plan your unique indexes too: if erased people are replaced by one placeholder, any unique index that includes that column must allow repeats of it.

The ASP.NET Core Data Protection key ring

Some values must be stored encrypted and read back later: a third-party API key, a connection string, a one-time link token. ASP.NET Core Data Protection does this. You ask for a protector with a purpose string, and it encrypts with authenticated encryption - AES-256-CBC with HMACSHA256 by default - using the current key of the key ring. Every protected value carries the id of the key that sealed it.

Encryption keeps working - until it cannot With a lost key ring, new values still encrypt, so nothing looks wrong - until the next deploy cannot decrypt anything sealed before. Good practice: refuse to start in production without a persistent key ring, keep the ring where every instance can read it, and back it up together with the data it protects.

Try it yourself: a thirty-minute PostgreSQL and EF Core lab

Everything in this lesson can be seen with Docker, two terminal windows and a small .NET console project. Run the steps side by side where they say both sessions - the interleaving is what makes the conflicts appear.

Common mistakes

Interview questions

Related