A Postgres branch per pull request, created through an API

One call opens a branch off your main database, one deletes it. The CI wiring, the naming rule that keeps cleanup reliable, and what a branch costs while it lives.

A database branch per pull request is two API calls on Infrai: POST /v1/db/branch/create with a project_id and a branch_name, and DELETE /v1/db/branch/delete/{project_id} when the PR closes. Each branch is a real Postgres database cloned from its parent, so migrations and seed data behave the way they will in production rather than the way a mocked schema does.

The API is the easy part. Getting cleanup right is what separates this from a slowly growing pile of forgotten databases.

Create the project once

curl -sS -X POST "https://api.infrai.cc/v1/db/project/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name": "checkout-service", "plan": "hobby", "postgres_version": "17"}'
{
  "ok": true,
  "data": {
    "project_id": "dbp_7Uu2kQxWvR4mBn8d",
    "db_id": "db_3kQ9mVzR1sXbNt",
    "name": "checkout-service",
    "vendor": "tencent_pg",
    "region": "ap-shanghai",
    "plan": "hobby",
    "state": "ready",
    "postgres_version": "17",
    "primary_branch": "main",
    "created_at": "2026-09-21T02:57:00Z"
  }
}

plan accepts hobby, pro or scale. primary_branch is what a new branch forks from unless you say otherwise.

Keep that project_id in your CI configuration. Everything else is derived from it.

Branch per PR

curl -sS -X POST "https://api.infrai.cc/v1/db/branch/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"project_id": "dbp_7Uu2kQxWvR4mBn8d", "branch_name": "pr-1482", "from_branch": "main"}'
{
  "ok": true,
  "data": {
    "db_id": "db_9wQ1zV6pLkS3dHyB",
    "branch_name": "pr-1482",
    "parent": "main",
    "created_at": "2026-09-21T02:58:11Z",
    "state": "ready"
  }
}

Name it from the PR number and nothing else. A branch named after a person, a ticket title or a feature is a branch nobody can safely delete six weeks later; pr-<number> can always be checked against the repository, which is what makes an automated sweep possible.

Read one back with GET /v1/db/branch/get/{project_id}?branch_name=pr-1482, and list them all with GET /v1/db/branch/list?project_id=dbp_7Uu2kQxWvR4mBn8d.

The CI wiring

#!/usr/bin/env bash
# .ci/db-branch.sh open|close <pr-number>
set -euo pipefail

API="https://api.infrai.cc"
PROJECT_ID="${INFRAI_DB_PROJECT_ID:?set INFRAI_DB_PROJECT_ID}"
ACTION="${1:?open or close}"
PR="${2:?pull request number}"
BRANCH="pr-${PR}"

case "$ACTION" in
  open)
    curl -sS -X POST "${API}/v1/db/branch/create" \
      -H "Authorization: Bearer ${INFRAI_API_KEY}" \
      -H "Content-Type: application/json" \
      -d "{\"project_id\": \"${PROJECT_ID}\", \"branch_name\": \"${BRANCH}\", \"from_branch\": \"main\"}"
    ;;
  close)
    curl -sS -X DELETE "${API}/v1/db/branch/delete/${PROJECT_ID}?branch_name=${BRANCH}" \
      -H "Authorization: Bearer ${INFRAI_API_KEY}"
    ;;
  *)
    echo "usage: $0 open|close <pr-number>" >&2
    exit 64
    ;;
esac

Hook open to the pull-request-opened event and close to closed-or-merged. Then add the sweep below, because the close hook will be missed — a force-closed PR, a cancelled workflow, a runner that died mid-job.

The sweep that makes it reliable

import os
import re

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
PROJECT_ID = os.environ["INFRAI_DB_PROJECT_ID"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
PR_BRANCH = re.compile(r"^pr-(\d+)$")


def branches() -> list[dict]:
    out, cursor = [], None
    while True:
        params = {"project_id": PROJECT_ID}
        if cursor:
            params["cursor"] = cursor
        resp = SESSION.get(f"{API}/v1/db/branch/list", params=params, timeout=25)
        resp.raise_for_status()
        data = resp.json()["data"]
        out.extend(data.get("items", []))
        cursor = data.get("next_cursor")
        if not cursor:
            return out


def sweep(open_pr_numbers: set[int], dry_run: bool = True) -> list[str]:
    """Delete branches whose PR is no longer open. The close hook WILL be missed —
    a cancelled workflow, a force-closed PR — so the sweep is the real cleanup and
    the hook is just the fast path."""
    removed = []
    for branch in branches():
        name = branch.get("branch_name") or ""
        match = PR_BRANCH.match(name)
        if not match or int(match.group(1)) in open_pr_numbers:
            continue
        if not dry_run:
            SESSION.delete(f"{API}/v1/db/branch/delete/{PROJECT_ID}",
                           params={"branch_name": name}, timeout=25).raise_for_status()
        removed.append(name)
    return removed


if __name__ == "__main__":
    print(sweep({1482, 1490}, dry_run=True))

Run it nightly. POST /v1/cron/create on the same key will call your endpoint on a schedule, so the sweep doesn’t need a scheduler of its own.

What a branch costs while it exists

This is the part worth understanding before you fan out fifty of them.

Opening a branch is a one-time fee, and the live figure is in the billing block of GET /v1/discovery/db.branch.create — verified 2026-09-21, and it’s a fraction of a cent rather than anything you’d budget for. But a branch is a real database occupying real disk, so it also accrues standing rent by occupied gigabyte over time, the same way stored objects do. That’s the cost that grows while you’re not looking, and it’s why the sweep matters more than the fee.

Read what actually accrued from GET /v1/account/usage; check what’s left with GET /v1/account/balance. Both are free calls, and both tell you the truth about your own account rather than what a guide claims. Rates move downward as vendor contracts improve, so read them rather than caching them.

HabitEffect on cost
Branch per PR, deleted on closebounded by concurrent PRs
Branch per PR, never deletedgrows monotonically forever
Nightly sweep as backstopcatches the missed hook
Long-lived “staging” brancha permanent database; budget for it

Limitations worth knowing up front

A branch is a clone, not a live replica: it doesn’t follow its parent after creation, so a branch opened on Monday doesn’t see Tuesday’s production writes. For a PR environment that’s usually what you want; for anything expecting fresh data it isn’t a good fit, and re-branching is the answer rather than waiting for a sync that won’t come.

There’s also no query endpoint here. This API provisions and lifecycle-manages databases; your application connects to Postgres directly with its own driver, which is the right split but does mean the connection string handling is yours.

Neon and Supabase both go further on the developer experience around branching — dashboards, Git integrations that open the branch for you, built-in connection pooling — and if branching is the main thing you’re buying, they’re worth pricing properly. What you get here instead is that the branch, the object storage for the PR’s test fixtures via PUT /v1/storage/object/put/{bucket}/{key}, the queue the test worker drains on POST /v1/queue/publish and the nightly sweep’s schedule all sit on one credential with one invoice — the PR environment stops being four accounts.

References

Browse more db developer guides