Per-tenant SQLite, a single Go binary, no external dependencies. The architecture pattern behind our regulated-industry platforms — and the open-source library that makes it practical.
Every multi-tenant SaaS product eventually faces the same architectural question: how do you isolate tenant data? The conventional answer is a shared relational database — Postgres or MySQL — with row-level security, a tenant_id column on every table, and a prayer that nobody forgets the WHERE clause. It works. It has worked for twenty years. But it carries costs that the industry has normalised without examining.
We chose a different path. Every tenant gets their own SQLite database. The application is a single statically linked binary with no external database dependency. The entire system — application, data tier, migrations — ships as one artifact and runs from a scratch container.
This is not a thought experiment. It is the architecture behind SafeOps365, our operational safety compliance platform for the energy sector, and the hospitality platforms we build for multi-property operators. It runs in production, in regulated environments, handling real workloads. This piece explains why we chose it, how it works, and the open-source library we built to make it practical.
Why Not Shared Postgres
The shared database model has real advantages: mature tooling, operational familiarity, a single connection string to manage. We are not arguing it is wrong. We are arguing that for a specific and growing class of application — multi-tenant SaaS in regulated industries, deployed on-premise or in sovereign infrastructure — the costs outweigh the convenience.
| Concern | Shared Postgres | Per-Tenant SQLite |
|---|---|---|
| Data isolation | Logical (row-level). One bug exposes all tenants. | Physical. Each tenant is a separate file. Cross-tenant access is structurally impossible. |
| Compliance audit | "Show me that tenant X cannot see tenant Y's data" requires proving query-level enforcement. | "The files are separate." Audit complete. |
| Tenant deletion | DELETE with careful cascading, verification, potential data remnants. | rm tenant-42.db. Cryptographic certainty of deletion. |
| Backup granularity | Full database dump. Restoring one tenant means restoring all or complex extraction. | Copy one file. Restore one file. Per-tenant SLAs become trivial. |
| Infrastructure dependency | Managed database service, connection pooling, failover configuration, network latency. | None. The data is local. The binary is the service. |
| Deployment | Application + database migration coordination. Schema drift risk across environments. | Single binary. Migrations run per-tenant on startup. One artifact. |
The compliance point deserves emphasis. In regulated sectors — energy, healthcare, financial services — auditors ask how tenant data is isolated. With row-level security, you are explaining a software enforcement mechanism and hoping the auditor trusts your implementation. With per-tenant SQLite, you are pointing at the filesystem. The data is in separate files. There is no mechanism to trust because there is no mechanism required.
The Architecture
The pattern is straightforward. The application is a single Go binary. HTTP requests arrive, middleware extracts the tenant identifier (from a JWT claim, a subdomain, an API key — the mechanism varies by product), and places it in the request context. Every database operation uses that context to route to the correct SQLite file.
Request flow: tenant identity extracted at the middleware layer, carried in context, sqliteproxy routes transparently to the correct database file.
The critical property is that the application code has no knowledge that it is multi-tenant. It calls db.ExecContext(ctx, ...) and db.QueryContext(ctx, ...) using the standard database/sql interface. The routing happens underneath. A developer writing a new feature writes single-tenant code. The isolation is an infrastructure concern, handled once, at the proxy layer.
sqliteproxy: The Library
sqliteproxy is the open-source library we built to make this pattern practical. It implements the Go database/sql interface, routes queries by a context key, manages per-tenant connection pools, and handles schema migrations per-tenant automatically.
Here is what a complete multi-tenant HTTP service looks like — the middleware that extracts the tenant, the proxy that routes the query, and the handler that knows nothing about multi-tenancy:
package main
import (
"encoding/json"
"log"
"net/http"
"uradical.io/go/sqliteproxy"
)
// The proxy — initialised once at startup
var db *sqliteproxy.Proxy
func main() {
var err error
db, err = sqliteproxy.New(sqliteproxy.Config{
Dir: "./data",
MaxConnections: 100,
Migrations: []string{
`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
},
})
if err != nil {
log.Fatal(err)
}
defer db.Close()
mux := http.NewServeMux()
mux.HandleFunc("POST /events", createEvent)
mux.HandleFunc("GET /events", listEvents)
// The tenant middleware wraps every route
log.Fatal(http.ListenAndServe(":8080", tenantMiddleware(mux)))
}
// tenantMiddleware extracts the tenant from the request and
// places it in context. This is the only multi-tenancy code
// in the entire application.
func tenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := r.Header.Get("X-Tenant-ID")
if tenant == "" {
http.Error(w, "missing tenant", http.StatusBadRequest)
return
}
ctx := sqliteproxy.WithOrgID(r.Context(), tenant)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// createEvent — standard handler. No knowledge of multi-tenancy.
// The proxy routes to the correct tenant database via context.
func createEvent(w http.ResponseWriter, r *http.Request) {
var body struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, err := db.ExecContext(r.Context(),
"INSERT INTO events (name) VALUES (?)", body.Name)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// listEvents — again, no tenant logic. Just standard database/sql.
func listEvents(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, name, created_at FROM events ORDER BY created_at DESC")
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer rows.Close()
var events []map[string]any
for rows.Next() {
var id int
var name, createdAt string
rows.Scan(&id, &name, &createdAt)
events = append(events, map[string]any{
"id": id, "name": name, "created_at": createdAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(events)
}
The point to notice: createEvent and listEvents are single-tenant code. They call db.ExecContext and db.QueryContext with the request context. The proxy reads the tenant from that context and routes to ./data/acme-corp.db or ./data/globex-inc.db transparently. A developer writing a new feature never thinks about tenancy. The middleware handles it once, at the edge, and the rest of the application is unaware.
The proxy handles:
Transparent routing
Reads the organisation ID from context and routes to the correct SQLite file. Application code is unaware of multi-tenancy.
Connection pool management
Per-tenant connection pools with configurable global limits. Idle tenants are evicted. Active tenants get priority.
Automatic migrations
Schema migrations run per-tenant on first connection. New tenants get the full migration history. Existing tenants get only what's new.
Observability
OpenTelemetry integration for tracing and metrics. Per-tenant query timing, connection counts, pool utilisation — all surfaced without application-level instrumentation.
The Deployment Model
Because SQLite is embedded and Go compiles to a static binary, the deployment artifact is a single file. No database server to provision. No connection string to configure. No migration runner to schedule. The binary is the service, and the service contains its own data tier.
# The entire production container
FROM scratch
COPY --from=builder /app/service /service
COPY --from=builder /app/data /data
EXPOSE 8080
ENTRYPOINT ["/service"]
The container has no shell, no package manager, no utilities. The attack surface is the binary and the kernel. This is not a security posture we layer on afterward — it is a structural consequence of the architectural choice.
For regulated environments, this matters. NIS2 requires essential entities to assess ICT supply chain security. GDPR requires defensible data isolation. The EU AI Act requires auditability for high-risk systems. A single-binary, no-external-dependency architecture with per-tenant physical data isolation answers all three requirements structurally rather than through policy documentation. The architecture is the compliance story.
When This Pattern Fits
This is not a universal architecture. It has genuine constraints and they should be named clearly.
It fits when:
- Tenants are organisations (companies, properties, departments) — not millions of individual users
- Data isolation is a hard requirement, not a nice-to-have
- You need to deploy on-premise or in sovereign infrastructure without cloud database dependencies
- Per-tenant backup, restore, and deletion must be operationally trivial
- The deployment artifact needs to be auditable and self-contained
- Write volumes per tenant are moderate — tens of thousands of transactions per day, not millions
It does not fit when:
- You need cross-tenant queries or analytics across the full dataset
- Individual tenants have write volumes that exceed what a single SQLite writer can sustain
- You have millions of tenants and the filesystem overhead of that many database files becomes a concern
- Your team is already operating Postgres well and the isolation requirement is met by row-level security
The honest position is: if shared Postgres with row-level security is working for you and your compliance requirements accept it, there is no reason to change. This pattern exists for the cases where it doesn't — where the isolation requirement is hard, the deployment environment is constrained, and the operational simplicity of a single self-contained artifact has genuine value.
What We Learned in Production
Migrations per-tenant are a superpower you don't expect. When a schema change needs to roll out, it happens automatically the next time each tenant's database is accessed. No migration runner. No coordination window. No "did staging get the migration?" conversations. It just happens, per-tenant, on demand. Here is what that looks like in practice:
// Launch day — v1 schema
proxy, _ := sqliteproxy.New(sqliteproxy.Config{
Dir: "./data",
Migrations: []string{
`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
},
})
// Six months later — a new column is needed. Just append.
proxy, _ := sqliteproxy.New(sqliteproxy.Config{
Dir: "./data",
Migrations: []string{
`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`ALTER TABLE events ADD COLUMN severity TEXT DEFAULT 'info'`,
},
})
sqliteproxy tracks which migrations each tenant has already run. When tenant A connects for the first time after this deploy, migration 2 runs automatically. Tenant B, who connected an hour later, gets it then. A brand new tenant C provisioned next week gets both migrations on first access. There is no migration runner to schedule, no coordination window to book, and no possibility of schema drift between tenants. The binary is the migration plan.
Tenant offboarding becomes trivial. When a client leaves, you delete their database file. GDPR right-to-erasure compliance becomes a filesystem operation with cryptographic certainty of deletion. No cascade logic. No "are we sure all the data is gone?" audit.
Debugging is dramatically simpler. A support ticket about tenant X means opening tenant-x.db in any SQLite client and looking directly at their data. No row-level filtering. No "make sure you're only looking at this tenant's records." The entire dataset is right there in one file.
The connection pool needs thought. With hundreds of tenants, the naive approach of keeping all connections open permanently doesn't scale. sqliteproxy's eviction policy handles this — idle tenants are closed, frequently accessed tenants stay warm. The global connection limit prevents resource exhaustion. Getting these numbers right required production tuning, and the OpenTelemetry integration made that process visible rather than guesswork.
SQLite is not the bottleneck you expect. WAL mode, proper connection pooling, and reasonable schema design handle the write volumes we encounter in practice without issue. The bottleneck in our systems is never SQLite. It is network I/O, upstream API latency, or business logic complexity — the same things that would be the bottleneck with Postgres. The benchmark data confirms this — the numbers below are from the sqliteproxy test suite, run on real hardware, comparing direct SQLite access against the proxy layer:
| Operation | Direct SQLite | Via sqliteproxy | Overhead |
|---|---|---|---|
| Insert | 10.3 μs/op | 11.4 μs/op | ~10% |
| Single query | 1.66 μs/op | 1.84 μs/op | ~11% |
| Query + iterate 10 rows | 12.9 μs/op | 13.2 μs/op | ~2% |
| Transaction (5 inserts) | 26.4 μs/op | 26.8 μs/op | ~1.5% |
| Prepared statement | 7.83 μs/op | 7.82 μs/op | <1% |
| Context routing overhead | 1.08 μs/op | 1.25 μs/op | ~170 ns |
The proxy adds approximately 170 nanoseconds of routing overhead per operation. At the scale of a typical HTTP request — where a database call that returns in 13 microseconds is sitting behind a network round trip measured in milliseconds — the multi-tenancy layer is invisible in production latency. You are paying less than a microsecond for complete physical data isolation. That is the trade-off.
The Bigger Point
This architecture is a specific instance of a general principle we apply across everything we build: the simplest system that meets the actual requirements is the most defensible one — legally, operationally, and over time.
Per-tenant SQLite is not simpler because it avoids complexity. It is simpler because it moves the isolation guarantee from application-level enforcement (where bugs can break it) to the filesystem (where they cannot). It moves the deployment model from multi-component coordination to a single artifact. It moves the compliance story from policy documentation to structural fact.
The industry defaults to shared databases because that is what the industry has always done. We default to the architecture that produces the fewest categories of failure. For the systems we build — operational safety platforms, compliance tooling, multi-tenant SaaS in regulated sectors — that architecture is this one.
sqliteproxy is open source and available on GitHub. The pattern is free. The library is free. If you're building multi-tenant services in Go and the isolation requirement matters, start there.
Related work worth reading:
- Ben Johnson — Litestream. Streaming replication for SQLite, making per-application SQLite viable for production workloads that need durability guarantees beyond the local filesystem.
- Fly.io — I'm All-In on Server-Side SQLite. Fly built their platform around SQLite-per-application as a first-class deployment pattern and explain why.
- Turso / libSQL — turso.tech/blog. A fork of SQLite designed for multi-tenant edge deployment, with extensive writing on why per-tenant SQLite is structurally superior for isolation.
- Richard Hipp — Appropriate Uses For SQLite. The creator of SQLite on what it handles, what it doesn't, and why the "toy database" reputation is decades out of date.
- Kent Beck — has publicly stated that SQLite is the only database most applications will ever need. From someone with his standing in software engineering, that carries weight.