Migrate to RunxBuild today!! Deploy More while Spending Less

Calculate your savings
unxBuild

GitHub as a Database: Where It Genuinely Works and the Line You Should Not Cross

Sean

Platform Writer

Sep 21, 2026
8 min read

GitHub works as a database for exactly one class of data: records that are small, edited by humans or by one process at a time, read far more often than written, and better for having a history. Site content, configuration, feature flags, a changelog, a list of a few hundred items that a build step turns into pages. It stops working the moment two writers race, the moment a page needs a filtered query at request time, or the moment writes exceed a few per minute, and those failures arrive as rate-limit errors, lost updates and a REST call in the hot path of every page view. The line is not fuzzy. Cross it and you are building a bad database on top of a good version control system.

GitHub as a Database: Where It Genuinely Works and the Line You Should Not Cross

This idea gets dismissed too quickly by people who run real databases and adopted too eagerly by people who do not want to. Both are wrong. The repository is an excellent store for some data and a terrible one for the rest, and the difference is mechanical rather than a matter of taste.

Table of contents

What a repository actually gives you

Strip away the branding and a Git repository is a content-addressed store of files with an immutable history, a diff for every change, a review workflow, and, through the hosting provider, an HTTP API to read and write those files with authentication and webhooks on every commit. That is a genuinely useful set of properties for data.

  • History for free. Every change has an author, a timestamp and a diff. Rebuilding audit logging on a relational database to match takes real work.
  • Review before write. A pull request is a write with an approval step. For data that humans edit, such as pricing tables or policy text, that is the workflow you would otherwise build.
  • Text tooling. JSON, YAML, CSV and Markdown are editable in any editor, greppable, and diffable. No client library, no schema migration tool.
  • Build-time reads are free. A static site generator reads the files at build time and ships HTML. No API call per page view, no database to keep up.

Notice that every one of those advantages is about data that changes slowly and is read by a build or a person. None of them is about serving requests.

The cases where it is the right answer

Content for a static site is the canonical case. Blog posts, documentation, a product catalogue with a few hundred entries, the team page: Markdown or JSON in the repository, rendered at build time. The corpus you are reading is stored this way, and a database would add operational cost and remove the pull-request review that keeps the content honest.

Configuration and feature flags come next. A flags file in the repository, read at build or at process start, gives you a reviewed, versioned, rollback-able switch without a flag service. The limit is that flipping a flag means a commit and a redeploy, which is fine for a weekly release and useless for a kill switch.

Small append-only logs also fit, provided one process writes them. A changelog, a list of incidents, a record of signups for a beta with a few hundred rows. A scheduled job that appends a line and commits is fine; a web form that commits on every submission is where the trouble starts, and the next section is about why.

Data that must be portable and inspectable in ten years is the last case. A repository full of CSV files will be readable by anything. A database dump from a managed service is readable by that database engine.

The four ways it fails

The failures are not edge cases. They are the normal behaviour of a system that was never designed for concurrent writes or request-time reads.

  1. Concurrent writes lose data. Two processes read the file, both modify it, both write. Git will reject the second push as non-fast-forward if they went through Git, or the API will return a SHA mismatch. Now your application needs retry logic with merge semantics, which is a database transaction implemented badly.
  2. Rate limits are the write ceiling. The hosted API allows a few thousand authenticated requests per hour across everything your token does. Each write is at least one request to fetch the current SHA and one to commit. A form that gets a burst of submissions turns into a queue of 403 responses.
  3. There is no query. Finding all products under a price, or the last ten orders for a user, means fetching the whole file and filtering in application memory, on every request. Fine for a hundred rows at build time. Unacceptable for ten thousand rows at request time.
  4. Latency is measured in hundreds of milliseconds. An API read is a network round trip to a service that is not optimised for it, and it sits in the critical path of the page. A database query on a private network returns in single-digit milliseconds.

There is a fifth, quieter failure: secrets. Data that includes personal information now lives in a repository with a clone history, forks, and a set of collaborators who were granted access to code, not to customer records. Deleting a row does not delete it from history.

The exact line, as a checklist

Keep the data in the repository if all of the following are true. Move it to a database the moment any one of them is false.

  • Writes come from a person via pull request, or from exactly one automated process.
  • Writes happen less than a few times per minute at peak.
  • Reads happen at build time or at process start, not per request.
  • The full dataset fits comfortably in a single file you would open in an editor, which in practice means low thousands of rows.
  • No row is personal data that a user could ask you to delete.
  • Nobody needs to filter, sort or join it at request time.

A surprising amount of data passes that test, and it is the data that people over-engineer with a database first. An equally surprising amount fails on the first line alone, and it is the data people try to force into a repository because the alternative involves a connection string.

Graduating without a rewrite

The good news is that the move, when it comes, is small if the code was honest about the boundary. Keep the data access behind one module that today reads a JSON file and tomorrow runs a query. The rest of the application never knew where the rows came from.

// data/products.js -- the only file that knows where products live
import { readFile } from "node:fs/promises";

export async function listProducts() {
  const raw = await readFile(new URL("./products.json", import.meta.url));
  return JSON.parse(raw);
}

// later, same signature, different body:
// export async function listProducts() {
//   const { rows } = await pool.query("SELECT * FROM products ORDER BY name");
//   return rows;
// }

The migration itself is a script that reads the file and inserts the rows. The repository keeps the historical file as a record of where the data came from, and the application starts reading from a managed Postgres or MySQL instance with backups, connection limits and a private network between it and the service. That is the moment the data becomes a product concern rather than a content concern, and it deserves the tooling built for it.

How this fits the rest of the stack

The repository-as-database phase is usually the last free phase of a project. The moment it ends, there is a database plan, a service plan and, often, storage for the files that used to sit beside the JSON, and knowing that total before the migration is better than discovering it after. The RunxBuild hosting calculator puts a managed Postgres or MySQL next to the service that reads it, with the plan prices side by side. Deploying the service itself straight from the GitHub repository keeps the workflow you already had for the code, just with the data living somewhere that can handle two writers at once.

Useful related references:

FAQ

Can I use GitHub as a database?

Yes, for a narrow class of data: content, configuration, flags and small datasets that are edited by people or by a single process, read at build time, and improved by having a history. It is not suitable for data with concurrent writers, request-time queries, more than a few writes per minute, or personal information.

What happens when two processes write to a GitHub-backed data file at once?

One of them loses. Either the push is rejected as non-fast-forward or the contents API returns a SHA mismatch, and your application must fetch, merge and retry. That is a database transaction reimplemented in application code, and it is the first sign the data should move to a real database.

How many writes per hour can GitHub handle as a datastore?

The authenticated API allows a few thousand requests per hour per token across all uses, and each write typically costs at least two requests. In practice that caps a data-writing workflow at a few hundred writes per hour before other API use, deploys and webhooks start failing with 403s.

Is it safe to store user data in a repository?

No. Repository history is permanent and cloneable, forks copy it, and collaborators with code access get data access. A user asking for deletion cannot be honoured without rewriting history. Personal data belongs in a database with access controls and real deletes.

When should I move from a JSON file in Git to Postgres?

When any of these becomes true: more than one writer, writes at request time, reads that need filtering or sorting at request time, thousands of rows, or personal data. Keep data access behind a single module from the start so the move is a script and one file change rather than a rewrite.

#github database#github as a database#git as a database#json file database#store data in github repo