PostgreSQL
The most advanced open-source relational database. Transactions, constraints, JSON support, full-text search, and complex data models.

Through TenantsDB, each tenant gets their own PostgreSQL database with the same schema, deployed from a blueprint. The proxy handles routing, TLS, query logging, and settings enforcement. Your app connects with any standard PostgreSQL driver. No SDK needed.

The proxy supports the full PostgreSQL wire protocol including extended query protocol, prepared statements, and streaming results. Control workspaces give your application a managed backend database with full DDL access. Tenant workspaces track schema changes as blueprints for deployment.


Credentials
TenantsDB issues three credential scopes. The proxy enforces which one can reach which database.

Every connection uses your project_id as the username (for example tdb_2abf90d3) and a proxy_password that depends on what you are connecting to. Pick the scope by matching the reach you need.

Project scope

The proxy_password printed when you created the project (or any sk_ key with scope_type=project). Reaches workspaces and the control plane API. Cannot reach individual tenant databases through the wire proxy. Use for admin tooling, the dashboard, and CLI calls.

Workspace scope

Generated by tdb apikeys create --scope-type workspace --scope-values myapp. Reaches the named workspace database AND every tenant database under that blueprint. Use for backend jobs that touch all customers of a blueprint, such as migrations, sweeps, and analytics ingest.

Tenant scope

Auto-generated on every tdb tenants create and returned in the response connection_string. Reaches one tenant database only. Use this for the per-customer database connection in your application.

Need a read-only or write-only key for the same tenant? Create one with tdb apikeys create --role read --scope-type tenant --scope-values wayne.

Every credential also carries a role (admin, write, or read). The proxy logs in to the backend as a native PostgreSQL role user that enforces permissions at the database layer. A read key cannot execute INSERT even if scope allows the connection. Scope and role stack: defense in depth.

Connect to Control Workspace
Your application's backend database. Users, billing, config. Full DDL and DML access, no blueprints.
Shell
psql "postgresql://tdb_2abf90d3:tdb_d2bf66ed7898c448@pg.tenantsdb.com:5432/controlplane_workspace?sslmode=require"

Control mode workspaces accept all DDL immediately. No blueprint versioning, no deployment step. Schema changes take effect as soon as you run them. Use this for your application's own tables that are not per-tenant.

The example above uses the project proxy_password. You can also create a workspace-scoped key for controlplane via tdb apikeys create --scope-type workspace --scope-values controlplane and use that instead.


Connect to Tenant Workspace
Where you design and iterate on your tenant schema. DDL changes are tracked as versioned blueprints.
Shell
psql "postgresql://tdb_2abf90d3:tdb_d2bf66ed7898c448@pg.tenantsdb.com:5432/myapp_workspace?sslmode=require"

Every CREATE TABLE, ALTER TABLE, or other DDL statement you run here is captured as a blueprint version. Deploy it to all tenants with tdb deployments create --blueprint myapp --all.

The project proxy_password works for any workspace in the project. If you want to give one engineer or one CI pipeline access to one blueprint only, create a workspace-scoped key and hand that out instead. Workspace-scoped keys also reach the tenants of that blueprint, which is useful for cross-tenant maintenance jobs.


Connect to Tenant Databases
Isolated production databases for your customers. CRUD only. DDL is blocked.

Each tenant has its own proxy_password. The value is returned in the connection_string field of the tdb tenants create response. Save it alongside the tenant record in your application, keyed by tenant_id.

wayne
Shell
psql "postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require"
globex
Shell
psql "postgresql://tdb_2abf90d3:tdb_e7b1f5c821a04d68@pg.tenantsdb.com:5432/myapp__globex?sslmode=require"
Same project, same host, different database, different password. Each tenant carries its own proxy_password so credentials never travel across customers. Lose wayne's password and only wayne is affected.

DDL statements run against a tenant database return a clear error: DDL not allowed on tenant databases, use workspace mode. Schema lives in the workspace and is deployed via blueprints.

If you connect to a tenant database using the project proxy_password or an sk_ key, the wire proxy rejects the handshake with: credential is project-scoped; direct-tenant connections require a tenant-scoped or workspace-scoped key. This is intentional. The project credential is for control plane and workspace access only.

Build Schema
Connect to your tenant workspace and create tables. Every DDL change is tracked as a blueprint version.
SQL
CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    balance DECIMAL(15,2) DEFAULT 0,
    created_at TIMESTAMP DEFAULT NOW()
);

INSERT INTO accounts (name, email, balance) VALUES
  ('Alice', 'alice@test.com', 1000),
  ('Bob', 'bob@test.com', 2000);
Only DDL statements (CREATE TABLE, ALTER TABLE, etc.) are tracked as blueprint changes. DML statements (INSERT, UPDATE, DELETE) run in the workspace only and are not deployed to tenants.

You can also import an existing schema from another database or use a template. See tdb workspaces schema --help for all options.


Drivers
Pick your language. Each section shows the standard low-level driver plus notes for the ORMs that build on it. Connection URLs are the same in every language; only the syntax for passing them differs.

All tenant connection examples below use the per-tenant proxy_password tdb_4f2c9d1ab7e8350c (which would belong to tenant wayne). In your application, look this value up by tenant_id at connect time.

Node.js

The standard low-level driver is node-postgres (the pg package). Sequelize, Drizzle, Knex, and TypeORM all use it under the hood. Prisma is the exception (it ships its own query engine and needs an adapter, see below).

Install
npm install pg
JavaScript
const { Client } = require('pg');

const client = new Client({
  connectionString: 'postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require',
  ssl: { rejectUnauthorized: false }
});

await client.connect();
const { rows } = await client.query('SELECT id, name, balance FROM accounts');
await client.end();
Sequelize
JavaScript
const { Sequelize } = require('sequelize');

const sequelize = new Sequelize(
  'postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require',
  { dialectOptions: { ssl: { require: true } } }
);

const Account = sequelize.define('Account', {
  name: { type: DataTypes.STRING(255), allowNull: false },
  email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
  balance: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 },
}, { tableName: 'accounts', timestamps: true });

await Account.create({ name: 'Alice', email: 'alice@test.com', balance: 1000 });
Drizzle, Knex, and TypeORM follow the same pattern: pass the connection string, and they use pg internally. No special configuration needed for TenantsDB.
Prisma (Node.js)

Prisma uses its own Rust query engine that does not connect through proxies cleanly. The fix is the official @prisma/adapter-pg adapter, which routes Prisma Client through node-postgres. Neon, Supabase, and other managed Postgres platforms use the same pattern.

Schema
prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Account {
  id        Int      @id @default(autoincrement())
  name      String   @db.VarChar(255)
  email     String   @unique @db.VarChar(255)
  balance   Decimal  @default(0) @db.Decimal(15, 2)
  createdAt DateTime @default(now()) @map("created_at")

  @@map("accounts")
}
Environment
.env
DATABASE_URL="postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require"
Client Setup
JavaScript
const { PrismaClient } = require('@prisma/client')
const { PrismaPg } = require('@prisma/adapter-pg')
const { Pool } = require('pg')

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })

await prisma.account.create({
  data: { name: 'Alice', email: 'alice@test.com', balance: 1000 }
})
Install
npm install prisma @prisma/client @prisma/adapter-pg pg
Define your schema in the tenant workspace using SQL (not prisma migrate), then deploy via blueprints. Prisma Client handles all runtime CRUD through the adapter. Run npx prisma generate after schema changes.
Python

Three drivers cover the Python ecosystem: psycopg (modern sync), asyncpg (async-only, used by FastAPI and modern stacks), and psycopg2 (the older sync driver still widely used). SQLAlchemy and Django ORM sit on top of these.

Install
pip install psycopg[binary]      # psycopg 3, recommended
pip install asyncpg              # async
pip install psycopg2-binary      # psycopg 2, legacy
psycopg 3
Python
import psycopg

with psycopg.connect(
    "postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require"
) as conn:
    with conn.cursor() as cur:
        cur.execute("SELECT id, name, balance FROM accounts")
        for row in cur.fetchall():
            print(row)
asyncpg
Python
import asyncio, asyncpg

async def main():
    conn = await asyncpg.connect(
        "postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require"
    )
    rows = await conn.fetch("SELECT id, name, balance FROM accounts")
    for row in rows:
        print(row)
    await conn.close()

asyncio.run(main())
SQLAlchemy
Python
from sqlalchemy import create_engine, Column, Integer, String, Numeric, DateTime
from sqlalchemy.orm import declarative_base, Session
from datetime import datetime

Base = declarative_base()

class Account(Base):
    __tablename__ = 'accounts'
    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(255), nullable=False)
    email = Column(String(255), unique=True, nullable=False)
    balance = Column(Numeric(15, 2), default=0)
    created_at = Column(DateTime, default=datetime.utcnow)

engine = create_engine(
    "postgresql+psycopg://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require"
)

with Session(engine) as s:
    s.add(Account(name='Alice', email='alice@test.com', balance=1000))
    s.commit()
Django uses psycopg through its DATABASES setting. Set ENGINE to django.db.backends.postgresql and pass the host, port, name, user, password, plus 'OPTIONS': {'sslmode': 'require'}.
Go

pgx is the standard PostgreSQL driver. GORM uses pgx internally for PostgreSQL, so anything that works with pgx works with GORM.

Install
go get github.com/jackc/pgx/v5
pgx
Go
package main

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
)

func main() {
    ctx := context.Background()
    conn, err := pgx.Connect(ctx,
        "postgresql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@pg.tenantsdb.com:5432/myapp__wayne?sslmode=require")
    if err != nil { panic(err) }
    defer conn.Close(ctx)

    rows, _ := conn.Query(ctx, "SELECT id, name, balance FROM accounts")
    for rows.Next() {
        var id int
        var name string
        var balance float64
        rows.Scan(&id, &name, &balance)
        fmt.Printf("%d: %s ($%.2f)\n", id, name, balance)
    }
}
GORM
Go
import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type Account struct {
    ID      uint    `gorm:"primaryKey"`
    Name    string  `gorm:"size:255;not null"`
    Email   string  `gorm:"size:255;uniqueIndex;not null"`
    Balance float64 `gorm:"type:decimal(15,2);default:0"`
}

// Workspace connection for schema work uses the project or workspace pw.
dsn := "postgresql://tdb_2abf90d3:tdb_d2bf66ed7898c448@pg.tenantsdb.com:5432/myapp_workspace?sslmode=require"
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})

// AutoMigrate against the workspace; the DDL is captured as a blueprint.
db.AutoMigrate(&Account{})
Pick one schema management approach per workspace. Mixing GORM AutoMigrate with raw SQL DDL causes constraint naming conflicts (GORM expects uni_users_email; raw SQL creates users_email_key). Let GORM own the schema or own it in SQL, not both.
Java

The standard PostgreSQL JDBC driver works directly. Hibernate, Spring Data JPA, jOOQ, and other JVM ecosystems sit on top of JDBC and inherit the same connection.

Maven
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.7.4</version>
</dependency>
JDBC
Java
import java.sql.*;
import java.util.Properties;

String url = "jdbc:postgresql://pg.tenantsdb.com:5432/myapp__wayne?sslmode=require";
Properties props = new Properties();
props.setProperty("user", "tdb_2abf90d3");
props.setProperty("password", "tdb_4f2c9d1ab7e8350c");

try (Connection conn = DriverManager.getConnection(url, props);
     Statement s = conn.createStatement();
     ResultSet rs = s.executeQuery("SELECT id, name, balance FROM accounts")) {
    while (rs.next()) {
        System.out.printf("%d: %s ($%.2f)%n",
            rs.getInt("id"), rs.getString("name"), rs.getBigDecimal("balance"));
    }
}
Hibernate and Spring Data JPA configure the same URL via their respective settings (spring.datasource.url, hibernate.connection.url). No special driver needed beyond org.postgresql:postgresql.
Ruby

The pg gem is the standard Ruby PostgreSQL driver. ActiveRecord (Rails) uses it internally, so Rails apps connect the same way through the database.yml file.

Install
gem install pg
pg gem
Ruby
require 'pg'

conn = PG.connect(
  host: 'pg.tenantsdb.com',
  port: 5432,
  dbname: 'myapp__wayne',
  user: 'tdb_2abf90d3',
  password: 'tdb_4f2c9d1ab7e8350c',
  sslmode: 'require'
)

conn.exec('SELECT id, name, balance FROM accounts') do |result|
  result.each { |row| puts row }
end

conn.close
Rails (ActiveRecord)
config/database.yml
production:
  adapter: postgresql
  host: pg.tenantsdb.com
  port: 5432
  database: myapp__wayne
  username: tdb_2abf90d3
  password: tdb_4f2c9d1ab7e8350c
  sslmode: require
For per-tenant connections in a multi-tenant Rails app, switch both the database key AND the password key at request time. Each tenant has its own proxy_password; look both values up by tenant_id.

Proxy Behavior
PostgreSQL-specific details about how the proxy handles your queries.
Wire Protocol

The proxy streams results row-by-row using the native PostgreSQL wire protocol. Memory usage stays flat even for large result sets. Extended query protocol and prepared statements are fully supported. Real backend ParameterStatus values are forwarded, including server_version, session_authorization, and IntervalStyle. Drivers see the actual PostgreSQL server version.

Connection Pooling (PgBouncer)

The TenantsDB proxy multiplexes your PostgreSQL connections through PgBouncer before they reach the backend. PgBouncer sits between the proxy and PostgreSQL itself, pooling backend connections so many client sessions can share a smaller set of real backend connections.

Pooler timeouts are tuned for normal application traffic and long-running analytical queries:

ParameterValueDescription
server_idle_timeout 1800s (30 min) How long an idle backend connection stays open before PgBouncer closes it.
client_idle_timeout 1800s (30 min) How long an idle client connection stays open before PgBouncer drops it.
pool_mode session Each client gets a dedicated backend connection for the duration of the session. Prepared statements, temporary tables, and session-state-dependent features work normally.

Client-side recommendations for connection pools (SQLAlchemy, HikariCP, node-postgres pool, etc.):

SettingRecommendedWhy
pool_pre_ping true Validate the connection before each use. Catches dropped or stale connections without surfacing errors to your application.
pool_recycle ≤ 1500s Recycle connections older than this. Keeps clients ahead of the 1800s pooler timeout so they never see a server-initiated close mid-request.
pool_size Workload-dependent Match to your concurrent query needs but stay well below the per-tenant ceiling. PgBouncer multiplexes for you, so over-sized client pools waste memory without buying throughput.
Credential Scope

The proxy enforces credential scope at handshake. Project credentials can connect to workspaces. Workspace-scoped credentials can connect to their workspace AND every tenant of that blueprint. Tenant-scoped credentials can only connect to their specific tenant database.

Using a project credential to reach a tenant database fails the handshake with:

Error
credential is project-scoped; direct-tenant connections require a tenant-scoped or workspace-scoped key

Using a workspace-scoped credential against a different blueprint's tenant fails with:

Error
credential scoped to workspaces [myapp], attempted workspace "other_app"
Role Enforcement

Each API key has a role (admin, write, or read) which the proxy maps to a native PostgreSQL role user on the backend connection. A read-role key attempting an INSERT fails with permission denied from PostgreSQL itself, not just the proxy.

Through the wire proxy the failure looks like:

Error
ERROR:  permission denied for table accounts

Through the control plane POST /tenants/{id}/query endpoint the same operation returns HTTP 403 with code: permission_denied. Same underlying PostgreSQL role, two different transports.

This is defense in depth. Even if the proxy were bypassed, the database role would still reject the write. Scope (set on the API key) controls which databases you can connect to. Role (mapped to a PostgreSQL user) controls what you can do once connected.

Cross-Protocol Guard

If you connect to a workspace or tenant whose blueprint targets a different database type (MySQL, MongoDB, Redis), the proxy rejects the connection with a clear pointer to the right proxy:

Error
blueprint "mysql_test" is MySQL, not PostgreSQL. Connect via the mysql proxy instead.
Connection Limits & Auth-Ban

The proxy enforces per-IP connection limits and a wire-level auth-ban tracker. These apply to PostgreSQL the same way they apply to MySQL, MongoDB, and Redis. Full values and rejection message formats live in Connection Limits & Rejection Behavior.

For PostgreSQL specifically, IP-level rejections arrive as a FATAL severity ErrorResponse with SQLSTATE 53300. Standard drivers (psycopg, asyncpg, JDBC, pgx) surface this as a normal connection exception. Example:

Error
FATAL: connection rejected: your IP is temporarily rate-limited after repeated failed auth attempts, retry in 47s

The TTL portion (retry in 47s) decrements on each retry while the ban is active, so client retry logic can parse it for accurate backoff.

Settings Enforcement

The proxy enforces max_rows_per_query, query_timeout_ms, and max_connections at the proxy level. These are configured per workspace and apply to all tenants using that blueprint.

Bulk Data

For bulk imports, use tdb workspaces import-full which connects directly to the source database, splits data by routing field, and creates tenants automatically. Multi-row INSERT statements work through the proxy for ongoing batch operations.

PostgreSQL COPY protocol is not supported through the proxy. Use the import endpoint or INSERT statements instead.
Limits

Standard PostgreSQL limits apply. TOAST handles large column values and the proxy does not add any size restrictions on top of what PostgreSQL enforces. There is no proxy-level packet size limit for PostgreSQL.