How to Implement Multi-Tenancy in Go Database Layer

Implement multi-tenancy in Go by adding a tenant_id column to your database and filtering all queries using a context value to ensure data isolation.

The shared database problem

You build a SaaS application. One customer signs up. Then another. Then a hundred. They all hit the same API endpoint, render the same frontend, and read from the same PostgreSQL or MySQL instance. The moment customer A starts seeing customer B's invoices, the product stops being software and starts being a liability. Multi-tenancy is the practice of keeping shared infrastructure strictly partitioned at the data layer. In Go, the standard approach relies on a single column, a context value, and a disciplined query pattern.

What multi-tenancy actually means

Think of a multi-tenant database like a large apartment building. Every resident has their own key, but they all walk through the same lobby and use the same mailroom. The building manager doesn't construct separate lobbies for each family. Instead, they put a lock on every apartment door and a label on every mailbox. The database is the building. The tenant_id column is the apartment number. Every query needs to check the label before handing over the data. If you skip the check, you are handing out master keys.

Row-level isolation is the most common implementation. You add a tenant_id column to every table that holds customer data. You attach that identifier to the incoming request. You append WHERE tenant_id = ? to every query. The database engine filters the rows before they ever reach Go's memory. This keeps storage costs low and simplifies backups, migrations, and scaling.

The context pipeline

Go's context package is designed exactly for this kind of request-scoped data. The context travels from the HTTP handler down through your service layer and finally to the database driver. It carries deadlines, cancellation signals, and request values. Here is the simplest way to attach a tenant identifier and use it in a query.

package main

import (
    "context"
    "database/sql"
    "fmt"
)

// GetUsers fetches users for a specific tenant from the database.
func GetUsers(ctx context.Context, db *sql.DB) ([]User, error) {
    // Extract tenant ID from context. Type assertion is safe if middleware guarantees it.
    tenantID, ok := ctx.Value("tenant_id").(string)
    if !ok {
        return nil, fmt.Errorf("missing tenant identifier in context")
    }

    // Pass context to driver for cancellation support and timeout propagation.
    rows, err := db.QueryContext(ctx, "SELECT id, name, tenant_id FROM users WHERE tenant_id = ?", tenantID)
    if err != nil {
        return nil, fmt.Errorf("query users: %w", err)
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        // Scan maps each row to the struct fields.
        if err := rows.Scan(&u.ID, &u.Name, &u.TenantID); err != nil {
            return nil, fmt.Errorf("scan user row: %w", err)
        }
        users = append(users, u)
    }
    return users, nil
}

The function starts by pulling the tenant identifier out of the context. Context values are untyped, so a type assertion converts the any value back to a string. If the middleware failed to attach it, the function returns early with a clear error. The QueryContext call passes the same context to the database driver. This gives the driver a way to cancel the query if the client disconnects or a deadline passes. The WHERE tenant_id = ? clause is the actual isolation boundary.

Go convention dictates that context.Context always goes as the first parameter to functions that perform I/O. The parameter is conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. This keeps the call chain predictable and makes it obvious which functions touch external systems.

How the query executes

When QueryContext runs, the Go database/sql package borrows a connection from its internal pool. It sends the parameterized SQL string to the database driver. The driver substitutes the ? placeholder with the actual tenant ID and forwards the query to the database engine. The engine scans the index on tenant_id, filters the matching rows, and streams them back. Go's rows.Scan reads each row into memory. The defer rows.Close() call returns the connection to the pool when the function exits.

If you forget to pass the context to the driver, you lose cancellation support. The query will run to completion even if the client closes their browser tab. Under load, this drains the connection pool and causes timeouts across unrelated requests. Always use QueryContext or ExecContext. Never fall back to Query or Exec in a web server.

Building the repository layer

Real applications do not write raw SQL strings in HTTP handlers. You wrap the extraction and query logic in a repository or service layer. You inject the tenant ID automatically at the edge using middleware. Here is how the middleware looks.

// AttachTenantID extracts the tenant from the request header and stores it in the context.
func AttachTenantID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Read tenant ID from a custom header or subdomain.
        tenantID := r.Header.Get("X-Tenant-ID")
        if tenantID == "" {
            http.Error(w, "missing tenant identifier", http.StatusUnauthorized)
            return
        }

        // Store the value in the context for downstream handlers.
        ctx := context.WithValue(r.Context(), "tenant_id", tenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

The middleware runs before your route handler. It reads the identifier, validates its presence, and creates a new context with the value attached. The modified request flows to the next handler with the tenant already bound. This keeps the business logic clean. Your handlers never parse headers. They just accept a context and trust the pipeline.

Now the repository layer can focus on data access without repeating the extraction logic.

// UserRepository handles all database operations for user data.
type UserRepository struct {
    db *sql.DB
}

// FindByID retrieves a single user, enforcing tenant isolation.
func (r *UserRepository) FindByID(ctx context.Context, userID int) (*User, error) {
    // Reuse the context extraction pattern.
    tenantID, ok := ctx.Value("tenant_id").(string)
    if !ok {
        return nil, fmt.Errorf("tenant context missing")
    }

    var u User
    // Two parameters prevent SQL injection and enforce row-level filtering.
    err := r.db.QueryRowContext(ctx, "SELECT id, name, tenant_id FROM users WHERE id = ? AND tenant_id = ?", userID, tenantID).Scan(&u.ID, &u.Name, &u.TenantID)
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, nil
        }
        return nil, fmt.Errorf("find user: %w", err)
    }
    return &u, nil
}

The repository method extracts the tenant ID, builds a parameterized query, and scans the result. The AND tenant_id = ? clause is mandatory. Even if you filter by primary key, you still verify the tenant. This prevents accidental cross-tenant leaks if the database schema changes or if an admin bypasses the middleware. The receiver name follows Go convention: one or two letters matching the type, like r for Repository. The if err != nil block is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible.

Where things break

Multi-tenancy fails in predictable ways. The first failure mode is forgetting the filter. If you write a raw query without the WHERE tenant_id = ? clause, the database returns every row. Go will not stop you at compile time. The compiler only checks types and syntax. You will get a runtime data leak instead. Always review raw SQL strings during code review. Treat missing tenant filters like missing authentication checks.

The second failure mode is context misuse. Developers sometimes store heavy objects in context or forget to propagate it. If you pass a stale context to QueryContext, the database driver cannot cancel the query when the request times out. You will see connection pool exhaustion under load. The driver will block until the query finishes or the pool limit is reached. If you accidentally shadow the context variable, the compiler rejects the program with an unused variable or undefined error depending on how you wrote it. Keep the context name consistent and pass it as the first parameter to every function that touches I/O.

The third failure mode is type assertion panics. Using ctx.Value("tenant_id").(string) without checking the ok result will panic if the value is missing or the wrong type. The runtime will halt the goroutine with an interface conversion: interface is nil, not string panic. Always use the two-value form of type assertion. Return a clear error instead of crashing the server.

The fourth failure mode is N+1 queries across tenants. If you fetch a list of orders and then loop through them to fetch line items, you might accidentally drop the tenant filter in the inner loop. The inner query will return line items for every tenant in the database. Batch queries or joins are safer. If you must loop, carry the tenant ID explicitly instead of relying on context. Context is convenient, but it is not a substitute for explicit parameters in tight loops.

Choosing your isolation strategy

Row-level filtering works for most SaaS products. It is cheap, simple, and scales horizontally. You do not need it for every project. Match the strategy to your data sensitivity and compliance requirements.

Use row-level filtering with a tenant_id column when you want maximum resource sharing and low operational overhead. Use a separate database schema per tenant when compliance rules require logical separation and you can afford extra connection overhead. Use a completely separate database instance per tenant when you handle highly regulated data like healthcare or finance records. Use plain single-tenant architecture when you sell dedicated deployments and do not need to share infrastructure.

Trust the type system and the database engine. Wrap the value or change the design.

Where to go next