MySQL
The most widely deployed relational database. Fast reads, broad ecosystem, mature tooling, and full SQL compatibility.

Through TenantsDB, each tenant gets their own MySQL 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 MySQL driver. No SDK needed.

The proxy supports the full MySQL wire protocol including text and binary protocols, prepared statements, multi-statement results, and TLS upgrades. 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 MySQL 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
mysql -h mysql.tenantsdb.com -P 3306 \
  -u tdb_2abf90d3 -p'tdb_d2bf66ed7898c448' \
  --ssl-mode=REQUIRED controlplane_workspace

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
mysql -h mysql.tenantsdb.com -P 3306 \
  -u tdb_2abf90d3 -p'tdb_d2bf66ed7898c448' \
  --ssl-mode=REQUIRED myapp_workspace

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
mysql -h mysql.tenantsdb.com -P 3306 \
  -u tdb_2abf90d3 -p'tdb_4f2c9d1ab7e8350c' \
  --ssl-mode=REQUIRED myapp__wayne
globex
Shell
mysql -h mysql.tenantsdb.com -P 3306 \
  -u tdb_2abf90d3 -p'tdb_e7b1f5c821a04d68' \
  --ssl-mode=REQUIRED myapp__globex
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 error code 1064 (42000): 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 INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    balance DECIMAL(15,2) DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

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. Unlike PostgreSQL, MySQL drivers do not share a single connection URL format. Each language has its own conventions, shown below.

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

mysql2 is the standard driver. Sequelize, Drizzle, Knex, TypeORM, and Prisma all use it under the hood.

Install
npm install mysql2
JavaScript
const mysql = require('mysql2/promise');

const conn = await mysql.createConnection({
  host: 'mysql.tenantsdb.com',
  port: 3306,
  user: 'tdb_2abf90d3',
  password: 'tdb_4f2c9d1ab7e8350c',
  database: 'myapp__wayne',
  ssl: { rejectUnauthorized: true }
});

const [rows] = await conn.execute('SELECT id, name, balance FROM accounts');
await conn.end();
Sequelize
JavaScript
const { Sequelize, DataTypes } = require('sequelize');

const sequelize = new Sequelize('myapp__wayne', 'tdb_2abf90d3', 'tdb_4f2c9d1ab7e8350c', {
  host: 'mysql.tenantsdb.com',
  port: 3306,
  dialect: 'mysql',
  dialectOptions: { ssl: { rejectUnauthorized: true } }
});

const Account = sequelize.define('Account', {
  name: { type: DataTypes.STRING(255), allowNull: false },
  email: { type: DataTypes.STRING(255), unique: true, allowNull: false },
  balance: { type: DataTypes.DECIMAL(15, 2), defaultValue: 0 },
}, { tableName: 'accounts', timestamps: true });
Drizzle, Knex, TypeORM, and Prisma all work with the same pattern. Pass connection options, they use mysql2 internally.
Python

Three drivers are commonly used: PyMySQL (pure-Python, easy install), mysql-connector-python (Oracle's official driver), and mysqlclient (libmysqlclient bindings, what Django uses by default). All three speak the same wire protocol.

Install
pip install PyMySQL                  # pure Python, no system deps
pip install mysql-connector-python   # Oracle's official
pip install mysqlclient              # Django's default. Needs libmysqlclient-dev
PyMySQL
Python
import pymysql

conn = pymysql.connect(
    host='mysql.tenantsdb.com',
    port=3306,
    user='tdb_2abf90d3',
    password='tdb_4f2c9d1ab7e8350c',
    database='myapp__wayne',
    ssl={'ssl': True}
)

with conn.cursor() as cur:
    cur.execute("SELECT id, name, balance FROM accounts")
    for row in cur.fetchall():
        print(row)

conn.close()
SQLAlchemy
Python
from sqlalchemy import create_engine

engine = create_engine(
    "mysql+pymysql://tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@mysql.tenantsdb.com:3306/myapp__wayne",
    connect_args={'ssl': {'ssl': True}}
)
Django uses mysqlclient via its DATABASES setting. Set ENGINE to django.db.backends.mysql and pass host, port, name, user, password, plus 'OPTIONS': {'ssl': {'ssl': True}}.
Go

go-sql-driver/mysql is the standard driver. GORM uses it for MySQL, so anything that works with database/sql works with GORM.

Install
go get github.com/go-sql-driver/mysql
database/sql
Go
package main

import (
    "database/sql"
    "fmt"

    _ "github.com/go-sql-driver/mysql"
)

func main() {
    dsn := "tdb_2abf90d3:tdb_4f2c9d1ab7e8350c@tcp(mysql.tenantsdb.com:3306)/myapp__wayne?tls=true&parseTime=true"
    db, err := sql.Open("mysql", dsn)
    if err != nil { panic(err) }
    defer db.Close()

    rows, _ := db.Query("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/mysql"
    "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 := "tdb_2abf90d3:tdb_d2bf66ed7898c448@tcp(mysql.tenantsdb.com:3306)/myapp_workspace?tls=true&parseTime=true"
db, _ := gorm.Open(mysql.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. Let GORM own the schema or own it in SQL, not both.
Java

Use the MariaDB JDBC driver. It speaks the standard MySQL wire protocol and is a drop-in replacement for the JDBC API. Hibernate, Spring Data JPA, and jOOQ all work on top of it without code changes.

Maven
<dependency>
  <groupId>org.mariadb.jdbc</groupId>
  <artifactId>mariadb-java-client</artifactId>
  <version>3.3.3</version>
</dependency>
MariaDB JDBC
Java
import java.sql.*;
import java.util.Properties;

String url = "jdbc:mariadb://mysql.tenantsdb.com:3306/myapp__wayne?sslMode=trust&useServerPrepStmts=true";
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"));
    }
}
Oracle's Connector/J driver currently has an authentication incompatibility with the TenantsDB proxy. MariaDB JDBC is the recommended Java driver and is API-compatible. We are investigating Connector/J support as a future improvement.
For production deployments, use sslMode=verify-ca or verify-full with the TenantsDB CA certificate. sslMode=trust encrypts the connection but skips certificate verification.
Ruby

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

Install
gem install mysql2
mysql2 gem
Ruby
require 'mysql2'

client = Mysql2::Client.new(
  host: 'mysql.tenantsdb.com',
  port: 3306,
  username: 'tdb_2abf90d3',
  password: 'tdb_4f2c9d1ab7e8350c',
  database: 'myapp__wayne',
  ssl_mode: :required
)

client.query('SELECT id, name, balance FROM accounts').each do |row|
  puts row
end

client.close
Rails (ActiveRecord)
config/database.yml
production:
  adapter: mysql2
  host: mysql.tenantsdb.com
  port: 3306
  database: myapp__wayne
  username: tdb_2abf90d3
  password: tdb_4f2c9d1ab7e8350c
  ssl_mode: required
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
MySQL-specific details about how the proxy handles your queries.
Wire Protocol

The proxy speaks the full MySQL 8.0 wire protocol. Both text protocol (COM_QUERY) and binary protocol (COM_STMT_PREPARE, COM_STMT_EXECUTE) are supported. Binary protocol preserves exact types so BIGINT, DOUBLE, DATETIME, and other native types round-trip without string conversion.

Connection Pooling (ProxySQL)

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

Query timeouts are tuned to accommodate interactive queries and long-running analytical workloads:

ParameterValueDescription
default_query_timeout 1800000ms (30 min) Maximum time a single query can run before ProxySQL kills it. Covers OLTP and most analytical workloads.

For analytical jobs that legitimately take longer than 30 minutes, chunk the work into smaller queries or use the bulk import endpoint instead of a single long-running statement. Client-side read timeouts should be set above the pooler timeout if you want pooler errors surfaced from the pooler rather than masked by a client-side TCP timeout.

SettingRecommendedWhy
connectTimeout 30s Reasonable upper bound on initial connect. Most successful connects complete well under a second.
readTimeout / read_timeout > 1800s for long queries For legitimate long-running queries, set higher than the pooler's 1800s so the pooler error reaches your code instead of a client-side TCP timeout.
maxLifetime / pool_recycle ≤ 1500s Recycle pooled client connections before the pooler closes them. Same pattern as PostgreSQL: stay ahead of server-initiated closes.
Authentication

The proxy uses mysql_native_password challenge-response. Your API key's proxy_password is hashed by the client with a server-generated salt. The password itself never traverses the wire in cleartext.

Oracle Connector/J has a known auth incompatibility. For Java apps, use the MariaDB JDBC driver. It is API-compatible with Connector/J and works without changes to Hibernate, Spring Data, or jOOQ.
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
ERROR 1045 (28000): Access denied: invalid credentials

The underlying server-side reason (which the proxy logs and surfaces to control plane callers) is:

Reason
credential is project-scoped; direct-tenant connections require a tenant-scoped or workspace-scoped key
Role Enforcement

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

Through the wire proxy the failure looks like:

Error
ERROR 1142 (42000): INSERT command denied to user 'tdb_u_xxxxxxxxxxxxxxxx_r'@'...' for table 'accounts'

Through the control plane POST /tenants/{id}/query endpoint the same operation returns HTTP 403 with code: permission_denied and the MySQL 1142 message in the error body. Same underlying MySQL 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 MySQL 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 (PostgreSQL, MongoDB, Redis), the proxy rejects the connection with a clear pointer to the right proxy:

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

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

For MySQL specifically, IP-level rejections arrive as a MySQL Error Packet with code 1040 and SQLSTATE 08004. Standard drivers (mysql2, PyMySQL, mysql-connector-python, go-sql-driver, MariaDB JDBC) surface this as a normal connection exception. Example:

Error
ERROR 1040 (08004): 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.

Packet Size

The MySQL wire protocol has a 16MB maximum packet size. Single rows, prepared statement parameters, and individual results must each fit within that limit. For larger payloads, use chunked inserts or the bulk import endpoint.

Unsupported

A few MySQL features are not currently supported through the proxy:

  • LOAD DATA LOCAL INFILE. Use multi-row INSERT or the bulk import endpoint instead.
  • COM_CHANGE_USER. Open a new connection with the new credentials.
  • Text-protocol PREPARE / DEALLOCATE. Use binary protocol prepared statements via your driver (this is what every driver does by default).