Skip to content

Content in Git Is a Database Decision

Putting your content in a repository looks like a tooling preference. It is actually a choice about consistency, history and who is allowed to write — and it has a specific expiry date you should decide in advance.

“Files as a database” gets dismissed as a hobbyist arrangement — fine until you’re serious, then you move to Postgres. That framing is wrong in both directions. It undersells what the file version actually gives you, and it hides the fact that the move has real triggers you can name up front.

What you get, stated precisely

Three things, and only the first is obvious.

History by construction. Not “we added an audit log”, but every change to every record, with an author, a timestamp, a diff and a message, forever, because that is what the storage layer is. You cannot forget to log a change. You cannot log it incorrectly. The interesting version of this is not the history itself but what it does to incidents: a destructive write is one revert away, and the question “what did this record say in June?” is a command rather than a project.

Review before write. A pull request is not a workflow bolted onto content — it is the only way content enters. That is what makes automated writers safe. An agent that proposes changes on a branch has a blast radius of exactly one branch. It never touches the live origin, never touches secrets, never touches the main line. The same agent pointed at a database has write access to production.

Schema validation as a build gate. Content that does not validate does not deploy, because the build fails. Not a warning in an admin panel that somebody will look at. A red build.

What you give up, also precisely

Concurrent writers. Two agents editing the same content branch will conflict, and the resolution is manual. One writer at a time is a real operational constraint, not a footnote.

Relational queries. “Every article by an author who has also written about X, sorted by Y” is a join, and you do not have joins. You have arrays and a build step. For a few thousand records this is genuinely fine — the whole corpus fits in memory and the build runs in seconds. For a few hundred thousand, it is not.

Anything per-user. No user-generated writes, no private data, no personalisation. A repository is a public artifact by construction, and the moment a record contains something that must not be public you have put it in the wrong place.

Name the triggers before you need them

The mistake is not choosing files. It is choosing files and never deciding what would change your mind — because then the migration happens in a panic, during an incident, with no plan.

Write down the reversal triggers when you make the decision. Any one of these appearing means you have outgrown it:

  1. User-generated writes.
  2. Per-user or otherwise private data.
  3. Relational queries across content.
  4. Multiple concurrent writers.

That list is the useful artifact. It converts a vague “we’ll move when it gets big” into four checkable conditions, and it means the answer to “shouldn’t this be in a database?” is a question you can actually resolve rather than a matter of taste.

The migration is boring, which is the point

The reason this trade is favourable is that leaving is cheap. Schema-validated JSON and markdown with structured frontmatter is already a normalised dataset with a declared shape. Moving it means writing an import script that reads files and inserts rows — a day of work, not a rewrite, because the modelling was done when the schemas were written.

Compare that to the reverse migration, which is what people actually end up doing: extracting content out of a CMS whose schema is implicit, whose relations are stored in a plugin’s serialised metadata, and whose history is a post_revisions table nobody has ever queried.

The asymmetry is the argument. Files are the cheap position to hold and the cheap position to leave.

One thing to get right immediately

Referential integrity is not free, and it is the single place this arrangement will bite you.

A schema validator checks the shape of a record. It does not check that category: "ai" refers to a category that exists — most frameworks only validate a reference at the moment a page resolves it, which means a dangling reference on a record nothing renders builds silently and 404s later.

So make it explicit, in a script that runs before the build:

for (const record of records)
  if (!categories.has(record.category))
    errors.push(`${record.file}: category "${record.category}" does not exist`)

Every “database” gives you foreign keys. If yours is a directory, you write them yourself. It is thirty lines, and it is the difference between a content model and a pile of files that happen to agree most of the time.

Keep reading

More on Development

Every vertical on this site carries its own writing, its upcoming events and its answers on one page — and the whole archive is available as a feed with a real publication date on every item.