Ferrule
The collar that joins you to your data.
Ferrule is a Rust-native command-line tool for querying relational databases. One static binary speaks PostgreSQL, MySQL, MSSQL, and SQLite. Oracle is optional via a feature flag.
Why ferrule?
Most database CLIs are tied to one engine — psql for Postgres,
mysql for MySQL, sqlcmd for MSSQL, sqlite3 for SQLite. Each is
excellent at its own backend, but moving between them means learning
five output conventions, five auth quirks, and five sets of
introspection commands.
Ferrule trades some backend-specific depth for a single consistent interface across all of them:
- One static binary, zero runtime dependencies for the four default
backends. No
libpq, nolibmysqlclient, nounixodbc. The binary drops onto a fresh box and works. - A unified type system. Every backend maps native types into a
single
Valueenum, so a JSON dump from Postgres and a JSON dump from MySQL look identical to your downstream tools. See Concepts. - Pipe-friendly defaults. JSON output by default, with
--format tablefor human-readable terminal use, plus CSV / YAML / raw for pipelines. - Same command surface across backends.
ferrule query,ferrule tables,ferrule describe,ferrule explainall work the same way against any supported backend. - Real credential hygiene. Passwords resolved through OS keyring, Docker secrets, env vars, or interactive prompt — never required in URLs, never logged. See Security.
When to reach for something else
Ferrule isn’t trying to replace the native shells when you need their specialty features. Reach for:
psql— when you need backslash commands like\dt+,\copy, or psql variables; or when you want the deepest Postgres introspection.mysql/mysqlsh— for MySQL-specific tooling likemysqldump, X Protocol, or replication management.sqlcmd/sqlpackage— for MSSQL deployment artifacts and T-SQL debugging.- A GUI (DBeaver, TablePlus, DataGrip) — when you want a visual ER diagram, an inline result-set editor, or stored-procedure authoring with syntax help.
usql— if you specifically need the Go ecosystem and a built- in ORM-like layer.
Ferrule is for terminal-first, scriptable, multi-backend work: ad-hoc queries during oncall, sanity checks across staging and production replicas, fixture export/import, daily KPI bookmarks, small data pipelines.
One-minute demo
# SQLite works out of the box — no setup
ferrule query "sqlite::memory:" "SELECT 1 + 1 AS answer;" --format table
# Postgres (requires running server)
ferrule query "postgres://user:pass@localhost/db" "SELECT * FROM users;"
# Save a connection, then reuse the name
ferrule conn add prod "postgres://app@db.example.com/myapp"
ferrule conn set-password prod
ferrule query prod "SELECT COUNT(*) FROM orders;"
# Interactive REPL
ferrule repl prod
> SELECT count(*) FROM orders WHERE created_at > now() - interval '1 day';
> \format table
> \q
Where to next
- New here? Start with Installation and the Quick Start.
- Want the mental model? Read How Ferrule Thinks — it’s the conceptual map for everything else.
- Looking up a flag? Jump to the CLI Reference.
- Something broken? Try Troubleshooting.
Installation
Ferrule ships as a single static binary. No runtime libraries are required for the four default backends (PostgreSQL, MySQL, MSSQL, SQLite). Oracle alone needs Oracle Instant Client — see Backends and Troubleshooting.
Pre-built binaries
The fastest path on any supported OS:
# Linux x86_64
curl -L https://github.com/rustpunk/ferrule/releases/latest/download/ferrule-linux-x64.tar.gz \
| tar xz
sudo mv ferrule /usr/local/bin/
Replace the asset name for other targets (ferrule-macos-arm64.tar.gz,
ferrule-macos-x64.tar.gz, ferrule-windows-x64.zip). The full list is
on the releases page.
On macOS, if Gatekeeper blocks the binary the first time, allow it under System Settings → Privacy & Security, or strip the quarantine attribute:
xattr -d com.apple.quarantine /usr/local/bin/ferrule
On Windows, drop ferrule.exe somewhere on %PATH% (or run it from
a folder you’ve added to PATH).
From source
Requires a stable Rust toolchain (install via rustup).
git clone https://github.com/rustpunk/ferrule.git
cd ferrule
# Default build — Postgres, MySQL, MSSQL, SQLite
cargo build --release --bin ferrule
# With Oracle (requires Instant Client at runtime, not at compile time)
cargo build --release --bin ferrule --features oracle
# Or install straight to ~/.cargo/bin
cargo install --path ferrule-cli
The release binary lands at ./target/release/ferrule.
Feature flags
| Feature | Default | Notes |
|---|---|---|
postgres | ✅ | tokio-postgres + rustls (no OpenSSL) |
mysql | ✅ | mysql_async |
mssql | ✅ | tiberius |
sqlite | ✅ | rusqlite with bundled SQLite |
oracle | ❌ | oracle crate; needs Instant Client at first connection |
Build a minimal binary with only the backends you need:
# SQLite-only — useful for embedding in fixture scripts
cargo build --release --bin ferrule --no-default-features --features sqlite
# Postgres + SQLite for a Postgres-focused workflow
cargo build --release --bin ferrule --no-default-features --features postgres,sqlite
Cutting unused backends shrinks the binary and trims the dependency graph; functionally there’s no difference for the backends you keep.
Verify the install
ferrule --version
# ferrule 0.x.y
# First config-touching command — creates ~/.config/ferrule if absent
ferrule conn list
# (no output if the registry is empty)
# Smoke-test SQLite (no server required)
ferrule query "sqlite::memory:" "SELECT 'ok' AS status;" --format table
If any of these fail, see Troubleshooting.
Next steps
- Quick Start — run your first real query.
- How Ferrule Thinks — the mental model.
Quick Start
This walkthrough takes you from a fresh ferrule install to a real database in under five minutes. SQLite for the warm-up, then a disposable Postgres in Docker for everything else.
Step 1 — your first query
SQLite needs no setup. Run:
ferrule query "sqlite::memory:" "SELECT 1 + 1 AS answer;" --format table
Output:
┌────────┐
│ answer │
├────────┤
│ 2 │
└────────┘
Drop the --format table flag and you’ll get JSON instead — that’s
the default. Either form is fine for now.
ferrule query "sqlite::memory:" "SELECT 1 + 1 AS answer;"
# [
# {
# "answer": 2
# }
# ]
Step 2 — a real database
Spin up a throwaway Postgres in Docker. This mirrors the test setup
in CLAUDE.md; copy and paste:
docker run -d --name ferrule-quickstart \
-e POSTGRES_PASSWORD=ferrule \
-e POSTGRES_USER=ferrule \
-e POSTGRES_DB=ferrule \
-p 127.0.0.1:15432:5432 \
postgres:17-alpine
# Wait for it to come up (~3 seconds)
until docker exec ferrule-quickstart pg_isready -U ferrule >/dev/null 2>&1; do
sleep 1
done
# Seed a tiny schema
PGPASSWORD=ferrule psql -h 127.0.0.1 -p 15432 -U ferrule -d ferrule -c "
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT,
signed_up TIMESTAMPTZ DEFAULT now()
);
INSERT INTO customers (name) VALUES ('Alice'), ('Bob'), ('Carol');
"
Now query it directly:
ferrule query "postgres://ferrule:ferrule@127.0.0.1:15432/ferrule?sslmode=disable" \
"SELECT * FROM customers;" --format table
Output something like:
┌────┬───────┬───────────────────────────────┐
│ id │ name │ signed_up │
├────┼───────┼───────────────────────────────┤
│ 1 │ Alice │ 2026-04-26 18:01:23.456+00:00 │
│ 2 │ Bob │ 2026-04-26 18:01:23.456+00:00 │
│ 3 │ Carol │ 2026-04-26 18:01:23.456+00:00 │
└────┴───────┴───────────────────────────────┘
When you’re done with the container:
docker stop ferrule-quickstart && docker rm ferrule-quickstart
Step 3 — save a named connection
Typing the full URL every time gets old. Add it to the registry:
ferrule conn add demo "postgres://ferrule@127.0.0.1:15432/ferrule?sslmode=disable"
ferrule conn set-password demo
# Password: ferrule
Now use the name:
ferrule query demo "SELECT COUNT(*) FROM customers;"
ferrule tables demo
ferrule describe demo customers
ferrule repl demo
The password is stored in your OS keyring under
keyring://ferrule/demo, never on disk in plaintext.
Step 4 — pipe-friendly defaults
The default output format is JSON. That’s chosen so output piped to
jq, awk, or another command “just works”:
ferrule query demo "SELECT * FROM customers" | jq '.[].name'
# "Alice"
# "Bob"
# "Carol"
If you’d rather see tables in your terminal, either pass --format table per command, or set a project default in .ferrule.toml
(see Configuration).
Step 5 — save a bookmark
For queries you run all the time, bookmarks beat shell aliases:
ferrule bookmark add daily-count \
"SELECT COUNT(*) FROM customers WHERE signed_up > now() - interval '1 day';" \
--connection demo
ferrule bookmark run daily-count
Positional parameters work too:
ferrule bookmark add by-name "SELECT * FROM customers WHERE name = ${1};" \
--connection demo
ferrule bookmark run by-name "'Alice'"
How passwords get resolved
Step 3 stored a password in the keyring. The next time you run
ferrule query demo "..." without a password on the URL, ferrule
walks this stack and stops at the first hit:
1. --password CLI flag (you didn't pass one)
2. password_url in profile (no .ferrule.toml profile yet)
3. FERRULE_DEMO_PASSWORD env (unset)
4. keyring://ferrule/demo ← FOUND (set by `conn set-password`)
5. Interactive prompt
6. Fail
This is described in detail in Concepts and Security. For now: storing in the keyring is the right default for a workstation.
Where to next
- Querying Data — output formats, paging, parameterized queries.
- Connections — profiles, environment
interpolation, registry vs
.ferrule.toml. - Interactive REPL — meta-commands, watch mode.
- Concepts — the abstractions everything is built on.
How Ferrule Thinks
Ferrule is built around a few small abstractions. Once you have them, the rest of the docs make sense at a glance. This chapter is the conceptual “map” of the tool — read it once and refer back as needed.
The unified Value type
Every supported backend speaks a different SQL dialect with different
native types: PostgreSQL has JSONB and UUID, MySQL has its own JSON,
MSSQL has UNIQUEIDENTIFIER and DATETIMEOFFSET, SQLite has dynamic
typing, Oracle has NUMBER(1) standing in for booleans. If you wrote a
query against each one, you’d get five different shapes back.
Ferrule maps every backend’s native types into a single Value enum:
Null | Bool | Int64 | Float64 | Decimal | String | Bytes
| Date | Time | DateTime | DateTimeTz | Json | Uuid | Array
Output formatters (table, json, csv, yaml, raw) only ever see
Value — they never touch driver-specific types. The practical
consequence: a JSON dump from PostgreSQL and a JSON dump from MySQL look
the same. A CSV from MSSQL pipes into the same awk script as a CSV from
SQLite. You can switch backends without rewriting downstream tooling.
The exact backend → Value mapping is documented in
Backends. When a native type doesn’t have a clean Value
equivalent (Postgres int4range, custom enums, composite types),
ferrule falls back to String with the value rendered as the driver
returns it.
Connection resolution
Most ferrule commands take a <CONNECTION> argument. That argument is
resolved in three steps:
- Raw URL. If
<CONNECTION>parses as a database URL (postgres://...,mysql://...,sqlite::memory:, etc.), ferrule uses it directly. - Profile from
.ferrule.toml. If not a URL, ferrule looks up the name in the loaded[connection.<name>]profile of the discovered config file. Profiles can declare apassword_urland other defaults. - Registry from
connections.toml. If no matching profile exists, ferrule falls back to the per-user registry at~/.config/ferrule/connections.toml(the file managed byferrule conn add/list/remove).
If none of those match, ferrule exits with a usage error and lists the available profiles so you can spot a typo.
Note: profiles take precedence over the registry. If you have both a registry entry named
prodand a[connection.prod]block in.ferrule.toml, the profile wins. This lets a project-local.ferrule.tomlshadow a global registry entry without modifying it.
The credential stack
Once ferrule has a URL, it needs a password. URLs that include a password
inline (postgres://user:secret@host/db) skip this entirely — but
inline passwords leak through process listings and shell history, so
keep them out of saved profiles. See Security for the
full rationale.
When the URL has no password, ferrule walks the hasp credential stack in order. The first source that returns a value wins:
--passwordCLI flag. Explicit, ephemeral, and intentionally loud. Useful for one-off debugging; bad for daily use.password_urlfrom the active profile. Whatever thepassword_urlin.ferrule.tomlresolves to, via the hasp URL scheme —env://,keyring://, orfile://.FERRULE_<NAME>_PASSWORDenv var. Legacy fallback.production→FERRULE_PRODUCTION_PASSWORD. Hyphens become underscores:local-db→FERRULE_LOCAL_DB_PASSWORD.- OS keyring at
keyring://ferrule/<name>. Whateverferrule conn set-password <name>stored. macOS Keychain, Windows Credential Manager, or libsecret on Linux. - Interactive prompt. Only fires if stdin is a TTY.
- Fail. Exit code 2 with a diagnostic listing every step it tried.
This is the canonical home for the credential stack. The Connections and Security chapters expand on the why of each step.
Output format selection
Ferrule supports five output formats: table, json, csv, yaml,
raw. The format is chosen in this order:
--format <fmt>on the command line.default.formatin the loaded.ferrule.toml.- The built-in default, which is
json.
The built-in default is JSON whether or not stdout is a TTY. If you
want pretty tables in your terminal, either pass --format table per
command or set format = "table" under [default] in your config.
When writing to a file with --output, the format applies to the file
contents.
Multi-statement semantics
All backends except SQLite accept multiple ;-separated statements in a
single round-trip. Ferrule supports this natively:
ferrule query prod "
INSERT INTO logs (msg) VALUES ('startup');
SELECT COUNT(*) FROM logs;
"
A few rules:
--limitand--offsetare not allowed with multi-statement SQL. The default limit (1000from[default]) counts. Set--limit 0to disable paging for batches.- Each result is reported separately. Result sets print to stdout;
DML row counts (“
N rows affected”) print to stderr. - SQLite is the only backend that does not support multi-statement
batches at the ferrule layer — split into separate
ferrule querycalls or use a script via--file. - Oracle uses a custom semicolon splitter that understands PL/SQL
blocks (
BEGIN … END,IF … END IF,LOOP … END LOOP,CASE … END CASE) so internal semicolons do not split prematurely. See Backends.
Where to next
- New users: Quick Start for a guided first query.
- Curating connections: Connections.
- Picking the right secret store: Security.
- Looking up a flag: CLI Reference.
- Things going wrong: Troubleshooting.
Connections
Raw Connection URLs
Pass a database URL directly as the first argument to any query, tables, describe, dump, watch command:
# SQLite (bundled — no external server)
ferrule query "sqlite::memory:" "SELECT 1;"
ferrule query "sqlite:///home/me/data/logs.db" "SELECT COUNT(*) FROM events;"
# PostgreSQL
ferrule query "postgres://ferrule:ferrule@localhost:5432/myapp?sslmode=disable" "SELECT * FROM users;"
# MySQL
ferrule query "mysql://root:pass@localhost:3306/myapp" "SHOW TABLES;"
# MSSQL
ferrule query "mssql://sa:pass@127.0.0.1:1433/myapp?trustServerCertificate=true" "SELECT 1;"
URL Formats
| Backend | URL Pattern | Notes |
|---|---|---|
| PostgreSQL | postgres://user:pass@host:port/db?sslmode=disable | sslmode values: prefer, require, disable |
| MySQL | mysql://user:pass@host:port/db | |
| MSSQL | mssql://user:pass@host:port/db?trustServerCertificate=true | Set trustServerCertificate=true for self-signed certs |
| SQLite | sqlite:///absolute/path or sqlite::memory: | :memory: does not work after ://; use the form above |
| Oracle | oracle://user:pass@host:port/service_name | Requires --features oracle |
URL Safety
Never embed passwords in URLs. Even when saved, connection registry entries are stored as plain TOML. Always omit the password from the URL and let Ferrule resolve it through the credential stack.
Passwords are redacted in all logs, error messages, and verbose diagnostics:
ferrule query --verbose "postgres://user:secret@host/db" "SELECT 1"
# [ferrule] Resolved URL: postgres://user:***@host/db
Security Recommendations
Ferrule resolves passwords through the hasp unified secret stack. The options below are listed from most secure to least secure.
Recommended: file:// (Docker / Kubernetes secrets)
Mount secrets as files. This is the most secure option because the secret is never exposed in environment variables (visible via /proc/<pid>/environ) or shell history.
[connection.production]
url = "postgres://app@db.example.com/myapp"
password_url = "file:///run/secrets/db_password"
Use ?raw=true if the file must be read verbatim without trimming the trailing newline.
Recommended: keyring:// (OS keyring)
Store passwords in the OS-native credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service). Secrets are encrypted at rest and isolated from other processes.
[connection.production]
url = "postgres://app@db.example.com/myapp"
password_url = "keyring://ferrule/production"
To store a password interactively:
ferrule conn set-password production
Acceptable for development: env://
Environment variables are convenient but visible to any process running as the same user via /proc/<pid>/environ.
[connection.staging]
url = "mysql://user@staging.internal/app"
password_url = "env://STAGING_DB_PASSWORD"
Avoid: --password flag
The --password flag leaks the secret into shell history (~/.bash_history, ~/.zsh_history) and process listings (ps). Only use it for one-off debugging or in CI pipelines where the secret is injected as an ephemeral variable.
# Leaks to history — do not use for real secrets
ferrule query production "SELECT 1;" --password "my-secret"
Avoid: passwords in URLs
URLs containing passwords are written to the connections registry (connections.toml) in plain text and may appear in process listings.
# Bad — password ends up in plain-text TOML
ferrule conn add production "postgres://user:secret@host/db"
Password Resolution Stack
If a connection URL does not contain a password, Ferrule resolves one automatically. The numeric order below is the exact fallback order; each step is attempted only if the previous one yielded no password.
1. Explicit --password flag (least secure)
ferrule query production "SELECT * FROM users;" --password "my-secret"
2. password_url from profile
If .ferrule.toml defines a password_url, Ferrule resolves it via hasp first.
[connection.production]
url = "postgres://app@prod.example.com/myapp"
password_url = "keyring://ferrule/production"
Supported hasp URL schemes:
env://VAR_NAME— environment variablekeyring://service/account— OS keyringfile:///path/to/secret— file on disk (trims trailing newline by default)
3. Per-connection environment variable (legacy)
export FERRULE_PRODUCTION_PASSWORD="my-secret"
ferrule query production "SELECT * FROM users;"
The variable name is derived from the connection name:
production→FERRULE_PRODUCTION_PASSWORDlocal-db→FERRULE_LOCAL_DB_PASSWORD
4. OS Keyring fallback
If no password_url is configured and the env var is unset, Ferrule falls back to the OS keyring at keyring://ferrule/<name>.
# Store password without putting it on disk
ferrule conn set-password production
# Password: ••••••••
# Remove from keyring
ferrule conn delete-password production
Passwords are stored as service=ferrule, account=<name> in the OS keyring.
5. Interactive prompt (TTY only)
$ ferrule query production "SELECT * FROM users;"
Password for 'production': ••••••••
6. Fail with diagnostic
If all five steps fail, Ferrule exits with code 2:
ferrule::connection
× Could not resolve password for 'production'
├─ No --password flag
├─ password_url not configured
├─ FERRULE_PRODUCTION_PASSWORD is unset
├─ keyring://ferrule/production: not found
└─ Terminal is not interactive
Project-Local Profiles
For team-shared settings, add a .ferrule.toml to your project root:
[default]
format = "json"
limit = 100
[connection.production]
url = "postgres://app@${DB_HOST:-db.example.com}/myapp"
Ferrule discovers .ferrule.toml automatically. The URL above uses optional environment interpolation — if DB_HOST is unset, it falls back to db.example.com.
Profiles in .ferrule.toml take precedence over registry entries
(~/.config/ferrule/connections.toml) that share the same name. This
lets a project-local .ferrule.toml shadow a global registry entry
without modifying it. See Concepts
for the full resolution order.
Configuration
Ferrule reads optional configuration from a TOML file. Every value is optional; a fresh install with no config file works fine. Use the config to set defaults, declare connection profiles, and wire up credential resolution.
Discovery order
Ferrule looks for a config file in this order; the first one that exists wins:
--config <path>on the command line../.ferrule.tomlin the current working directory (project-local).~/.config/ferrule/ferrule.toml(or the platform equivalent —dirs::config_dir()).
If none of those exist, ferrule uses the built-in defaults.
Two TOML files, two purposes. Don’t confuse the config file (
.ferrule.toml/ferrule.toml) with the connections registry (~/.config/ferrule/connections.toml). The registry is managed byferrule conn add/list/remove; it stores raw URLs only. The config file holds defaults, profiles, and credential declarations. Profile entries take precedence over registry entries with the same name.
Example .ferrule.toml
[default]
format = "table" # default output format when no --format given
limit = 1000 # default LIMIT applied to single-statement queries
timeout = 30 # connection timeout in seconds (reserved; not yet enforced)
[connection.production]
url = "postgres://app@${DB_HOST:-db.example.com}/myapp"
password_url = "keyring://ferrule/production"
[connection.staging]
url = "mysql://user@staging.internal/app"
password_url = "env://STAGING_DB_PASSWORD"
[connection.local]
url = "sqlite:///./local.db"
Once present, queries can use the bare profile name:
ferrule query production "SELECT 1;"
[default] block
| Field | Type | Default | Notes |
|---|---|---|---|
format | string | "json" | One of table / json / csv / yaml / raw. Overridden per-call by --format. |
limit | integer | 1000 | Default LIMIT for single-statement queries. Set to 0 to disable. Multi-statement batches reject any non-zero value. |
timeout | integer | 30 | Connection timeout in seconds. Reserved field; the current driver layer doesn’t enforce it. |
The limit = 1000 default catches the “I forgot to add LIMIT and
the table has 50M rows” case at low cost. If you mostly run
multi-statement DDL or batch jobs, set limit = 0 here so you don’t
have to pass --limit 0 on every call.
[connection.<name>] blocks
Each block defines a profile that resolves to a database URL plus credential metadata.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Database URL. May contain ${ENV_VAR} and ${ENV_VAR:-default} interpolation. |
password_url | string | no | Hasp URL for credential resolution. See below. |
headers | table | no | Reserved; not yet used by the driver layer. |
Environment interpolation
${VAR} in a URL is replaced with the value of environment variable
VAR at config-load time. ${VAR:-fallback} provides a literal
fallback if VAR is unset or empty. $$ is an escaped literal $.
[connection.production]
url = "postgres://app@${DB_HOST:-db.example.com}:${DB_PORT:-5432}/myapp"
Behavior:
- Unset variables without
:-fallback are left as the literal${VAR}text — ferrule does not fail. (This usually causes a later parse error; it’s loud, not silent.) - Empty values are treated like unset for the
:-fallback. - Bare
$VAR(no braces) is not interpolated.
password_url
Tells ferrule where to fetch the connection password via the hasp credential resolver. Schemes:
| Scheme | Example | Notes |
|---|---|---|
env:// | env://STAGING_DB_PASSWORD | Reads an env var |
keyring:// | keyring://ferrule/production | OS keyring (service / account) |
file:// | file:///run/secrets/db_password | File on disk; trims trailing newline. Append ?raw=true to disable trimming |
password_url is consulted before the legacy FERRULE_<NAME>_PASSWORD
env var and the implicit keyring://ferrule/<name> lookup, so it lets
you point at any secret store you like without renaming env vars.
The full credential stack and security trade-offs live in Security. Quick guide:
- Production with mounted secrets →
file://. - Workstation →
keyring://. - CI / dev →
env://.
Notable defaults that surprise people
- The default output format is
json, nottable. To get pretty tables interactively, setformat = "table"here or pass--format tableper call. - The default
limitis1000, applied to every single-statement query that doesn’t override it. Setlimit = 0if you don’t want this. - Profiles take precedence over the registry. If a project-local
.ferrule.tomland~/.config/ferrule/connections.tomlboth defineprod, the profile wins. - Typos are now hard errors. Every
[default]and[connection]key is validated; a misspelled field likessh_hosttproduces an error at config-load time instead of being silently ignored.
Per-profile output defaults
Output settings under a profile aren’t supported yet — the
[connection.<name>] block accepts url, password_url, and
headers only. To override defaults for a specific environment,
pass the relevant flags at call time, or maintain a separate
.ferrule.toml per project.
Environment variable overrides
| Variable | Effect |
|---|---|
FERRULE_<NAME>_PASSWORD | Legacy password fallback for connection <name> |
RUST_LOG | Enable structured logging from ferrule and the driver crates |
FERRULE_CONFIG is not currently a recognized override; use
--config on the command line.
Where the registry lives
Separate from the config file:
~/.config/ferrule/connections.toml
Format:
[entries.production]
name = "production"
url = "postgres://app@db.example.com/myapp"
[entries.local]
name = "local"
url = "sqlite:///./local.db"
Don’t hand-edit this file — use ferrule conn add / remove /
list (see Connections). The registry is
deliberately simpler than .ferrule.toml; if you need
password_url or env interpolation, switch to a profile.
Security
Ferrule talks to databases, which means it handles credentials, opens TLS sessions, and emits diagnostics that could leak secrets if it weren’t careful. This chapter is the canonical home for ferrule’s security guarantees and how to use them.
Threat model in one paragraph
The defaults assume a single-user workstation or a CI runner where the attacker is another process running as the same OS user (or someone who later reads your shell history). Ferrule cannot defend against an attacker who already has root, who can read raw memory, or who controls the database server itself. Within that scope, the goal is: secrets should not be written to the filesystem in plaintext, should not be visible to other processes, and should not be logged.
Password best practices, ranked
The same options ranked top-to-bottom by safety. Match the option to your environment.
1. keyring:// — best for workstations
OS-native credential store. macOS Keychain, Windows Credential Manager, or Linux Secret Service (libsecret / GNOME Keyring / KWallet). Secrets are encrypted at rest and unlocked by the desktop session.
[connection.production]
url = "postgres://app@db.example.com/myapp"
password_url = "keyring://ferrule/production"
Store a password without ever putting it on disk:
ferrule conn set-password production
# Password: ••••••••
Limitations: requires an unlocked keyring session. If you SSH in
without -Y or run from cron, the keyring is often locked — see
Troubleshooting.
2. file:// — best for containers
A file mounted at a known path. Docker swarm secrets, Kubernetes
secrets, and systemd LoadCredential= all work this way. The file
should be chmod 0600 and owned by the runtime user.
[connection.production]
url = "postgres://app@db.example.com/myapp"
password_url = "file:///run/secrets/db_password"
Pros over env://: not visible in /proc/<pid>/environ, not inherited
by child processes, easy to rotate (overwrite the file).
Append ?raw=true if the file must be read verbatim — by default hasp
trims a single trailing newline, which matters for tools like
docker secret create that don’t add one.
3. env:// — convenient, fine for development
An environment variable. Visible to any process running as the same
user via /proc/<pid>/environ, and inherited by every child process.
[connection.staging]
url = "mysql://user@staging.internal/app"
password_url = "env://STAGING_DB_PASSWORD"
A legacy convention FERRULE_<NAME>_PASSWORD is also recognized
without an explicit password_url:
export FERRULE_STAGING_PASSWORD="...";
ferrule query staging "SELECT 1"
4. Interactive prompt
If everything else falls through and stdin is a TTY, ferrule prompts. The secret never touches disk, env, or shell history. Slow if you run many queries; fine for occasional use.
5. --password flag — last resort
Leaks the secret into shell history (~/.bash_history,
~/.zsh_history) and process listings (ps aux). Useful only for
ephemeral CI snippets where the secret comes from an injected
variable, or for one-off debugging where you’ll rotate the password
afterward.
ferrule query prod "SELECT 1" --password "$INJECTED_FROM_CI"
0. Passwords in URLs — don’t
# Bad: ends up in plain-text TOML, shell history, and ps output
ferrule conn add prod "postgres://user:secret@host/db"
Save the URL without the password, and let ferrule resolve credentials through the stack:
ferrule conn add prod "postgres://user@host/db"
ferrule conn set-password prod
TLS posture per backend
By default, ferrule verifies TLS certificates for every backend that
supports TLS. The --insecure flag disables both certificate-chain
verification and hostname verification — it does not just skip one or
the other.
| Backend | TLS by default | How to require it | Self-signed dev cert |
|---|---|---|---|
| PostgreSQL | Negotiated; client decides | ?sslmode=require (encryption only) or ?sslmode=verify-full (chain + hostname) | ?sslmode=require + --insecure |
| MySQL | Off unless server requires; rustls-based | Server-side require_secure_transport | --insecure |
| MSSQL | Always negotiated; cert often self-signed | (default) | ?trustServerCertificate=true or --insecure |
| SQLite | N/A — local file or memory | — | — |
| Oracle | Server-configured | TNS listener config | TNS-side, not URL |
When ferrule runs with --insecure, it prints a warning to stderr:
Warning: --insecure disables TLS certificate verification.
That line is intentional — make sure your scripts don’t grep stderr for emptiness.
When --insecure is defensible
- A local Docker container with a self-signed cert that you control.
(The MSSQL test setup in
CLAUDE.mdis the canonical example.) - Reaching a private replica over an already-encrypted tunnel
(Wireguard, SSH
-L). - Bootstrapping when the production cert hasn’t been provisioned yet.
When it isn’t
- Anything across the public internet.
- Production DBs accessed over corporate VPNs (the VPN protects routing, not the DB session).
- Anywhere the diagnostic warning will be silently captured into a log and never read.
Hasp URL schemes side-by-side
password_url and ferrule’s internal credential resolver both use
hasp URLs. The three relevant schemes
expose a secret to a different attacker class:
| Scheme | What it is | Visible to | Survives reboot |
|---|---|---|---|
env://NAME | Environment variable in this process | Anything reading /proc/<pid>/environ for this PID + every child | No (set per-shell) |
keyring://service/account | OS keyring entry | Code running as the same desktop session | Yes |
file:///path | File on disk | Anyone who can read(2) the file | Yes |
Pick by what attacker you’re defending against, not by what’s most
ergonomic. A leaked env:// secret is a leaked secret; a leaked
keyring:// lookup that fails is just a re-prompt.
OS keyring details
Per-platform backends used by ferrule conn set-password and any
keyring:// URL:
- macOS — Keychain (login keychain by default).
- Windows — Credential Manager.
- Linux — Secret Service over D-Bus (libsecret-compatible: GNOME Keyring, KeePassXC’s secret-service, KWallet via secret service).
Stored under service=ferrule, account=<connection-name>. You can
inspect with the platform’s native tool (security find-generic-password -s ferrule -a production on macOS, Seahorse on GNOME, etc.).
Redaction policy
A few invariants ferrule guarantees:
- Passwords are wrapped in
secrecy::SecretStringin memory.Debugformatting prints[REDACTED]; the underlying buffer is zeroed on drop. - URLs are redacted before logging. The
--verboseflag prints the resolved URL with the password component replaced by***:
Search the codebase for[ferrule] Resolved URL: postgres://user:***@host/dbdisplay_redactedif you want to see exactly what is and isn’t elided. - Error messages from drivers may not redact. A driver-level
error from
tokio-postgresortiberiusis wrapped, not parsed. In rare cases it could echo a portion of the URL. If you see a redaction failure, that’s a bug worth filing.
Caveats
- Inline passwords in a URL bypass the credential stack entirely. This is by design — sometimes you really do want the URL to be self-contained — but means ferrule cannot help you redact it. Don’t save such URLs to the registry.
ferrule conn addwrites to plain-text TOML. Use it only for URLs without passwords. Add passwords withset-passwordafterward.- The daemon (
ferrule conn start) holds connections open. That means it also holds the resolved password in memory for the daemon’s lifetime. If you don’t trust other processes on the box, prefer one-shot connections without--daemon.
Querying Data
ferrule query is the workhorse: it takes a connection and a SQL
statement, executes it, and prints the result.
ferrule query "sqlite::memory:" "SELECT 1 + 1 AS answer;"
Connection can be a raw URL, a profile from .ferrule.toml, or a
saved registry name (see Connections for the full
resolution order).
Where the SQL comes from
Three options, mutually exclusive:
# 1. Inline as the second argument
ferrule query demo "SELECT * FROM users LIMIT 10;"
# 2. From a file
ferrule query demo --file queries/users_active.sql
# 3. From stdin (good for `cat … | ferrule …` pipelines)
echo "SELECT 1;" | ferrule query demo --stdin
Output formats
| Format | Flag | Best for |
|---|---|---|
| Table | --format table | Human-readable terminal output |
| JSON | --format json | Piping to jq, APIs, structured tooling |
| CSV | --format csv | Spreadsheets, data pipelines |
| YAML | --format yaml | Human-readable structured data, config-y outputs |
| Raw | --format raw | Tab-delimited; minimal noise for shell scripts |
| Markdown | --format markdown (alias md) | GFM pipe tables for docs / PR descriptions / issue comments |
| JSONL | --format jsonl (alias ndjson) | Streaming, one JSON object per line, jq -c friendly |
| HTML | --format html | Static <table> snippets for emails, reports, dashboards |
The default is JSON, regardless of whether stdout is a terminal
or a pipe. To get pretty tables interactively, either pass --format table per command, or set format = "table" under [default] in
.ferrule.toml (see Configuration).
Write to a file directly with --output:
ferrule query demo "SELECT * FROM events" --format csv --output events.csv
Large results: size guards
The default table / json rendering buffers the whole result in
memory so it can lay out columns. To keep a pathological result from
OOM-killing the process, ferrule-sql applies three constant-memory
size guards to every read:
| Guard | Default | What it caps |
|---|---|---|
max_cell_bytes | 64 MiB | a single value (e.g. a giant bytea / TEXT) |
max_row_bytes | 256 MiB | one row’s summed cell payloads |
max_total_buffered_bytes | 1 GiB | the running total an eager buffered render may accumulate |
When a guard is exceeded, the query fails fast with a structured diagnostic naming the offending row / column and the cap, instead of allocating without bound. The error message points you at the options below.
These are coarse ceilings, not an RSS budget — they are checked
incrementally as rows decode, so the checker itself never allocates
proportional to the data it inspects. Embedders tune them through
ferrule_sql::SizeGuards on the connection; a 0 cap disables that
dimension.
Working past the cap
When a result legitimately exceeds the default ceilings you have two options:
- Raise the relevant cap. For a known-wide column or a deliberately
large export, lift
max_cell_bytes/max_row_bytes/max_total_buffered_bytes(embedders set these on the connection viaferrule_sql::SizeGuards). - Ingest row-at-a-time instead of buffering. Embedders that need to
process an unbounded result under a fixed memory budget use the
Connection::query_cursorstreaming API, which pulls from a native database cursor atO(batch)memory and never materializes the whole result — the guards then apply per row, so a single oversized cell still fails fast while the stream as a whole is unbounded in length. The row-orientedcsv/jsonlformats are the natural sink for that streamed output.
Paging
--limit and --offset push paging down to the server when possible:
# Postgres, MySQL, SQLite — emits LIMIT N OFFSET M
ferrule query demo "SELECT * FROM users ORDER BY id" --limit 25 --offset 50
# MSSQL — emits OFFSET M ROWS FETCH NEXT N ROWS ONLY
ferrule query mssql_db "SELECT * FROM users ORDER BY id" --limit 25 --offset 50
A few quirks worth knowing:
- There is a default limit. The shipped default in
[default]islimit = 1000. If you don’t pass--limitand don’t override the default, ferrule still appendsLIMIT 1000. To disable globally, setlimit = 0in[default]. --limit 0disables paging for the current call. Useful when you really want every row.- Multi-statement batches reject
--limit/--offset. Ferrule has no safe way to inject paging into one statement of many. Pass--limit 0(or set the global default to 0) for batches. - Auto-injection is single-statement only. If your SQL already has
LIMIT/OFFSET, ferrule does not stack a second one — it just uses what you wrote.
Multi-statement batches
Backends with native multi-statement support (PostgreSQL, MySQL, MSSQL) or
PL/SQL block support (Oracle) can take several ;-separated
statements in one round trip:
ferrule query demo --limit 0 "
INSERT INTO logs (msg) VALUES ('startup');
SELECT COUNT(*) FROM logs;
"
Behavior:
- Result sets print to stdout, one per
SELECT, prefixed with a-- Result set Nseparator. - DML row counts print to stderr as
-- Statement N: K rows affected. - SQLite at the current driver layer doesn’t
multiplex multiple statements per query — split into separate
ferrule querycalls or use a stored procedure / script invocation specific to that backend. - Oracle now supports semicolon-separated batches including
anonymous PL/SQL blocks (
BEGIN … END), control structures (IF … END IF,LOOP … END LOOP,CASE … END CASE), and mixed DML/DDL. See Backends.
Transactions
Wrap the entire statement batch in a single backend-aware outer
transaction with --begin. The batch (whether one statement or many)
commits at the end unless --rollback is set.
# Implicit COMMIT at end (--begin alone implies COMMIT):
ferrule query demo --begin \
"INSERT INTO orders (user_id, total) VALUES (1, 99.50);
UPDATE users SET last_order_at = NOW() WHERE id = 1"
# Explicit ROLLBACK — useful for dry-run / read-only snapshot
# semantics even on statements that would otherwise write.
ferrule query demo --begin --rollback \
"DELETE FROM cache WHERE expires_at < NOW(); SELECT count(*) FROM cache"
# --commit is the explicit, redundant form. It exists for symmetry
# with --rollback when scripts compose flags dynamically.
ferrule query demo --begin --commit "INSERT INTO t VALUES (1)"
Semantics:
- The batch runs on a single live connection, so every statement (and the BEGIN / COMMIT / ROLLBACK that bracket it) ride the same TCP round trip.
- Inner statement failure: ferrule best-effort rolls back the
wrapping transaction, prints
[ferrule] inner statement failed — rolled back wrapping transactionon stderr, and surfaces the original SQL error as exit code 4. --rollbackon success: ferrule still rolls back and prints[ferrule] explicit ROLLBACK (--rollback)on stderr; exit code 0 when SQL + ROLLBACK both succeed.--commitrequires--begin(clap exit 2).--rollbackrequires--begin.--commitand--rollbackconflict (clap exit 2).--begin --daemonis rejected: the daemon path doesn’t guarantee per-tick connection affinity, which would silently dissolve the transaction across pool checkouts.--begin --watchis rejected: each watch tick would reopen a separate transaction.--begin --bench Nwraps the whole loop in ONE outer transaction (not N separate ones). Pairs with--bench --rollbackfor side-effect-free microbenchmarks.- Backend SQL emitted:
BEGIN/COMMIT/ROLLBACKfor PostgreSQL, MySQL, SQLite.BEGIN TRANSACTION/COMMIT TRANSACTION/ROLLBACK TRANSACTIONfor MSSQL. Oracle has implicit transactions, so the BEGIN is a no-op and only COMMIT / ROLLBACK are sent.
Read-only --begin SELECT is legal — useful for snapshot-isolation
requirements where the entire read must observe one consistent
point-in-time view.
Parameterized queries
Use ${name} placeholders and pass values via --param:
ferrule query demo \
"SELECT * FROM events WHERE severity = \${sev} AND created_at > \${date}" \
--param "sev=error" \
--param "date=2026-01-01"
Type inference rules:
true/false→ boolean.- An integer (
-42,0,1000) →Int64. - A decimal (
3.14,-0.5) →Float64. - Anything else →
String(properly quoted for the backend’s dialect).
Backend-specific placeholder forms ($1 for Postgres, ? for
MySQL, etc.) are handled internally — you always write ${name}
in the SQL, and ferrule rewrites it.
Loading many parameters from JSON
cat > params.json <<'JSON'
{
"id": 42,
"name": "Alice",
"active": true
}
JSON
ferrule query demo 'SELECT * FROM users WHERE id = ${id} AND name = ${name}' \
--param-file params.json
--param-file and inline --param flags can be combined; later
flags override earlier ones for the same key.
Dry run
Preview the substituted SQL and the resolved (redacted) URL without opening a connection or executing anything:
ferrule query demo 'SELECT * FROM users WHERE id = ${id}' \
--param "id=42" --dry-run
# [ferrule] Would execute against postgres://ferrule:***@127.0.0.1:15432/ferrule
# [ferrule] SQL: SELECT * FROM users WHERE id = 42
Use --dry-run to confirm parameter substitution and URL resolution
when debugging a ferrule invocation.
TLS verification (--insecure)
--insecure disables both certificate-chain and hostname
verification. It applies to the current call only and prints a
warning to stderr:
ferrule query mssql_demo "SELECT 1;" --insecure
# Warning: --insecure disables TLS certificate verification.
For the narrower MSSQL-specific case, append
?trustServerCertificate=true to the URL — it accepts the self-
signed cert without disabling hostname checks. See
Security for the full
breakdown.
Useful flag combinations
# See where time is being spent
ferrule query demo "SELECT * FROM big_table" --timing
# Echo the resolved (redacted) URL and SQL before executing
ferrule query demo "SELECT 1" --verbose
# Get JSON with timing info for benchmarking
ferrule query demo "SELECT * FROM events" --format json --timing
# Pipe to a file with explicit format
ferrule query demo "SELECT * FROM events" --format csv --output events.csv
# Route through the connection-pooling daemon (after `ferrule conn start`)
ferrule query demo "SELECT 1" --daemon
See also: Schema Introspection, Bookmarks, Reference for the full flag list.
Schema Introspection
Two commands cover the bulk of “what’s in this database?” workflows:
ferrule tables lists tables, and ferrule describe shows column
shapes for one of them. Both work the same way against every backend.
When to use them
- Exploring an unfamiliar database. Drop in, list tables, describe the ones that look interesting. Same flow on Postgres, MySQL, MSSQL, SQLite, or Oracle.
- Scripted schema checks. CI step that asserts a table has the columns it should, or a pre-deploy guard that fails if a migration is missing.
- ORM / codegen scaffolding. Generate model definitions from live introspection rather than hand-rolled SQL.
- Onboarding. A new engineer can
tables+describetheir way around without learning each backend’s metadata catalog by heart.
For the deepest backend-specific introspection (Postgres \d+ with
indexes, constraints, and triggers; MySQL SHOW CREATE TABLE; MSSQL
sys catalogs; etc.) reach for the native shell. Ferrule’s surface is
the cross-backend subset that’s worth unifying.
List tables
ferrule tables demo
JSON output is the default:
[
{"table_name": "customers"},
{"table_name": "events"},
{"table_name": "schema_migrations"}
]
For a terminal-friendly view, add --format table:
ferrule tables demo --format table
The list comes from the backend’s metadata catalog: pg_catalog
(plus information_schema) on Postgres, information_schema on
MySQL, sys.tables on MSSQL, sqlite_schema (formerly
sqlite_master) on SQLite, ALL_TABLES on Oracle. Views and
materialized views are excluded — only base tables.
Describe a table
ferrule describe demo customers
[
{
"column_name": "id",
"data_type": "integer",
"is_nullable": "NO",
"column_default": "nextval('customers_id_seq'::regclass)"
},
{
"column_name": "name",
"data_type": "text",
"is_nullable": "YES"
},
{
"column_name": "signed_up",
"data_type": "timestamp with time zone",
"is_nullable": "YES",
"column_default": "now()"
}
]
Column-level fields are taken from the backend’s catalog. Coverage
varies by backend — column_default is reliably populated on
Postgres / MySQL / MSSQL, less so on SQLite (older versions don’t
expose it via pragma_table_info).
Scripted use with jq
Because the default format is JSON, downstream shell scripts compose naturally:
# All NOT NULL columns in `customers`
ferrule describe demo customers \
| jq '.[] | select(.is_nullable == "NO") | .column_name'
# Tables in the `audit` schema (Postgres-style)
ferrule tables demo \
| jq -r '.[].table_name | select(startswith("audit_"))'
# Quick "does this table have an index_id column?" check
ferrule describe demo events | jq -e '.[] | select(.column_name == "index_id")' \
> /dev/null && echo "yes" || echo "no"
If you need Postgres-style schema-qualified names, use the SQL form
directly — the tables command lists the current schema’s tables,
not all schemas.
What you won’t get
By design, tables and describe expose only a backend-agnostic
subset. The following do not unify well across backends, so ferrule
doesn’t try:
- Indexes, constraints, foreign keys. Use the native shell or a direct SQL query.
- Enum variants (Postgres
CREATE TYPE … AS ENUM, MySQL inlineENUM). - Composite types and ranges (Postgres-specific).
- Generated / computed column expressions beyond the bare
column_defaultfield. - Triggers, stored procedures, sequences. Query the relevant
catalog (
pg_catalog.pg_proc,INFORMATION_SCHEMA.ROUTINES, etc.) withferrule queryfor now.
If a future workflow needs structured access to one of these, file
an issue — the Value enum has the headroom (e.g., Json for
constraint detail), the question is which subset stays cross-
backend.
REPL equivalents
Inside ferrule repl, the meta-commands \dt and \d <table> do
the same thing as the CLI variants:
> \dt
> \d customers
See Interactive REPL.
Reference
Both commands accept the standard output flags (--format,
--output, --limit, --offset, --timing, --verbose) plus
the connection flags (--insecure, --daemon). See
CLI Reference for the full list.
Bookmarks
Bookmarks save frequently-run queries by name so you don’t retype them
or maintain a separate scratch directory of .sql files. Think shell
aliases, but cross-shell, with positional parameters and a connection
hint baked in.
Saving bookmarks
# Simple query
ferrule bookmark add active-users \
"SELECT * FROM users WHERE active = true;" \
--connection dev
# With a positional parameter placeholder
ferrule bookmark add user-by-id \
"SELECT * FROM users WHERE id = ${1};" \
--connection dev
# Long query — wrap in single quotes (or a heredoc into stdin)
ferrule bookmark add recent-sales '
SELECT product, SUM(amount) AS total
FROM orders
WHERE created_at > now() - interval '"'"'7 days'"'"'
GROUP BY product
ORDER BY total DESC;
' --connection production
The --connection hint is optional but recommended. Without it,
bookmark run requires a --connection flag at every call.
Naming convention
Plain names work fine:
ferrule bookmark add count-all "SELECT COUNT(*) FROM ${1};"
Dotted names are treated as connection hints — the first segment
suggests the connection to use, so a single bookmark run works
without --connection:
# The "pg." prefix tells `run` to look for a connection named `pg`
ferrule bookmark add pg.select_users "SELECT id, name, email FROM users;"
When the prefix doesn’t match a saved connection, ferrule falls back
to the explicit --connection flag or the [connection.default]
profile.
Listing bookmarks
ferrule bookmark list
Name | SQL
--------------------------------------------------------------
active-users | SELECT * FROM users WHERE active = true;
user-by-id | SELECT * FROM users WHERE id = ${1};
recent-sales | SELECT product, SUM(amount) as total FR...
pg.select_users | SELECT id, name, email FROM users;
Running bookmarks
# Run a simple bookmark
ferrule bookmark run active-users
# Run with positional parameters
ferrule bookmark run user-by-id 42
# Override the format
ferrule bookmark run recent-sales --format table
# Override the connection
ferrule bookmark run pg.select_users --connection staging
# Combine with paging
ferrule bookmark run active-users --limit 10 --offset 20
# Edit before running — opens your $EDITOR
ferrule bookmark run active-users --edit
${1}, ${2}, … are replaced with the positional arguments you
pass after the name. Missing parameters leave the placeholder
intact (which the database will then reject).
Positional only. Bookmarks support
${1}/${2}/ etc., not the named${name}placeholders thatferrule query --paramuses. Named params are CLI-only because they require explicitname=valuemapping at call time.
--editopens the bookmarked SQL in your$EDITOR. You can modify the statement before it runs. This is useful for ad-hoc tweaks without creating a new bookmark.
Common patterns
Per-environment health checks
ferrule bookmark add prod.health "SELECT 1;" --connection prod
ferrule bookmark add staging.health "SELECT 1;" --connection staging
ferrule bookmark add dev.health "SELECT 1;" --connection dev
for env in prod staging dev; do
ferrule bookmark run "$env.health" --format raw && echo "$env: ok" || echo "$env: down"
done
Daily KPI dashboard line
ferrule bookmark add daily-signups \
"SELECT COUNT(*) FROM users WHERE created_at::date = current_date;" \
--connection prod
Drop it in a cron / launchd / Task Scheduler job piping to your notification channel of choice.
Parameterized lookup-by-id
ferrule bookmark add user-by-email \
"SELECT * FROM users WHERE email = ${1};" \
--connection prod
ferrule bookmark run user-by-email "'alice@example.com'"
Note the quotes: bookmark substitution is text-level. Wrapping the argument in single quotes makes it a string literal in SQL.
Export of the day
ferrule bookmark run user-growth --format csv > "growth-$(date +%F).csv"
Deleting
ferrule bookmark delete user-by-id
Where bookmarks live
~/.config/ferrule/bookmarks.toml
The file format is plain TOML and safe to commit if you want to version-control your team’s saved queries:
[active-users]
sql = "SELECT * FROM users WHERE active = true;"
connection = "dev"
[user-by-id]
sql = "SELECT * FROM users WHERE id = ${1};"
connection = "dev"
["pg.select_users"]
sql = "SELECT id, name, email FROM users;"
(Quoted keys are required when the bookmark name contains a ..)
Bookmarks in the REPL
The same bookmark file is shared with the REPL meta-commands:
> SELECT * FROM users WHERE active = true;
> \bookmark save active-users
Bookmark 'active-users' saved.
> \bookmark list
- active-users
- daily-metrics
> \bookmark run active-users
> \bookmark delete active-users
See Interactive REPL for the full meta-command surface.
Interactive REPL
Launch with any connection — a raw URL, a profile name, or a saved registry entry:
ferrule repl "sqlite::memory:"
ferrule repl demo
ferrule repl prod
The REPL is a TTY-only experience. If you need batch input from a
script, use ferrule query --stdin instead (see
Querying).
What you get
- Readline editing via
rustyline— arrow keys, line editing, Ctrl-R reverse history search, Ctrl-A / Ctrl-E for line endpoints. - Multi-line SQL. Statements that span multiple lines are
collected until a trailing
;. Cancel a partially-typed statement with Ctrl-C. - Persistent history at
~/.cache/ferrule/history. Shared across sessions for the current user. - Session parameters (
\param) and bookmarks (\bookmark) work the same as their CLI counterparts and share the same bookmark file. - Watch mode (
\watch) re-runs the previous query on a configurable interval without leaving the REPL.
Meta-commands
Backslash prefix:
| Command | Description |
|---|---|
\q | Quit the REPL |
\conn [name] | Switch connection or show current |
\d [table] | Describe table; without an argument, behaves like \dt |
\dt [schema] | List tables |
\format [fmt] | Set output format (table, json, csv, yaml, raw) |
\limit [N] | Set row limit (0 to clear) |
\timing [on|off] | Toggle timing display (per-query connect/query/format breakdown) |
\verbose [on|off] | Toggle verbose logging (resolved URL + SQL) |
\param <name> <value> | Set a session parameter for ${name} placeholders |
\param clear | Clear all session parameters |
\param list | List currently-set parameters |
\bookmark save <name> | Save the previous SQL as a bookmark |
\bookmark list | List saved bookmarks |
\bookmark run <name> [args…] | Run a bookmark with positional params |
\bookmark delete <name> | Delete a bookmark |
\g | Re-run the previous SQL statement |
\explain | Toggle EXPLAIN-mode (wraps every executed query) |
\explain on|off|toggle | Set EXPLAIN-mode explicitly |
\explain <sql> | Explain a single query (one-shot) |
\watch | Re-run previous SQL every 5s until Ctrl+C |
\watch <secs> | Re-run previous SQL every <secs> seconds |
\watch <sql> | Watch the given SQL (5s default) |
\dump <table> | Dump a table to stdout |
\load <file> INTO <table> | Load a CSV/JSON file into a table |
\help | Show in-REPL help |
Example session
$ ferrule repl demo
Connected to postgres://ferrule:***@127.0.0.1:15432/ferrule
Type \help for commands, \q to quit.
ferrule> \format table
ferrule> SELECT id, name, signed_up FROM customers ORDER BY id;
┌────┬───────┬───────────────────────────────┐
│ id │ name │ signed_up │
├────┼───────┼───────────────────────────────┤
│ 1 │ Alice │ 2026-04-26 18:01:23.456+00:00 │
│ 2 │ Bob │ 2026-04-26 18:01:23.456+00:00 │
│ 3 │ Carol │ 2026-04-26 18:01:23.456+00:00 │
└────┴───────┴───────────────────────────────┘
ferrule> \timing on
Timing: on
ferrule> SELECT COUNT(*) FROM customers;
┌──────┐
│ count│
├──────┤
│ 3 │
└──────┘
[ferrule] connect: 0ms (pooled)
[ferrule] query: 2ms
[ferrule] total: 2ms
ferrule> \bookmark save customer-count
Bookmark 'customer-count' saved.
ferrule> \param email 'alice@example.com'
ferrule> SELECT * FROM customers WHERE name = ${email};
(...)
ferrule> \q
Bookmarks in the REPL
REPL bookmarks share the ~/.config/ferrule/bookmarks.toml file
with CLI bookmarks. Saving from the REPL captures the previous
SQL statement as the bookmark body:
ferrule> SELECT * FROM users WHERE active = true;
ferrule> \bookmark save active-users
Bookmark 'active-users' saved.
ferrule> \bookmark list
- active-users
- customer-count
ferrule> \bookmark run active-users
Positional parameters work the same as on the CLI:
ferrule> \bookmark run user-by-id 42
See Bookmarks for the full surface.
Watch mode inside the REPL
Re-run the previous query on a fixed cadence without leaving the REPL:
ferrule> SELECT COUNT(*) FROM events;
ferrule> \watch # repeats the COUNT(*) every 5s
ferrule> \watch 3 # repeats the COUNT(*) every 3s
ferrule> \watch SELECT now(); # watch a different SQL at 5s default
Press Ctrl+C to exit the watch loop and return to the prompt
without leaving the REPL. Intervals are integer seconds (u64);
sub-second intervals are not supported in v1.
If \explain mode is on, the watched SQL is wrapped through
EXPLAIN on each iteration so plan changes can be tracked
visually.
Mid-loop control (\watch interval N / \watch stop) is reserved
for a future release; issuing them outside a running loop prints a
polite error. Watch prints a header on each iteration. The --diff
mode (only print on change) is also supported in watch loops; see
Advanced Features.
When to leave the REPL
Switch to one-shot ferrule query invocations when:
- You’re scripting (no TTY).
- You need
--output FILEto write to a file. - You want predictable exit codes for use in
&&chains.
Switch to the REPL when:
- You’re iterating on a query and want history + multi-line editing.
- You want timing or verbose mode toggled per-statement.
- You want to alternate between SQL and
\d/\dtexploration.
History maintenance
Persistent history at ~/.cache/ferrule/history grows unbounded.
If it gets unwieldy, truncate it manually:
tail -n 1000 ~/.cache/ferrule/history > ~/.cache/ferrule/history.new
mv ~/.cache/ferrule/history.new ~/.cache/ferrule/history
Or delete it entirely to start fresh — the file is recreated on the next REPL launch.
EXPLAIN, Dump, and Watch
Three features that go beyond plain query: looking at execution
plans, moving data in and out of tables, and watching a query change
over time. None are essential — but they’re the things you’ll reach
for once you’re past the basics.
EXPLAIN
ferrule explain shows the execution plan for a query without
running it (the --analyze variant does run it). The output format
varies by backend; ferrule wraps the SQL in the right syntax and
returns whatever the backend produces.
When to use it
- A query is slower than you expected. EXPLAIN tells you whether there’s a sequential scan, a missing index, or a join blowing up.
- Before shipping a query you’ve never seen the plan for. Cheap insurance.
- After adding an index — confirm the planner is actually using it (it sometimes isn’t, especially with low row counts).
Basic usage
# Default: estimated plan, no execution
ferrule explain demo "SELECT * FROM customers WHERE name = 'Alice';"
# With actual statistics — runs the query
ferrule explain demo "SELECT * FROM customers WHERE name = 'Alice';" --analyze
--analyze and DML
--analyze would execute the statement, which is a problem for
INSERT / UPDATE / DELETE / DDL: you’d cause real changes by
asking for a plan. Ferrule detects modifying statements and silently
falls back to the non-executing variant, so ferrule explain demo "DELETE FROM users WHERE id = 1" --analyze is safe to run — you
get the estimated plan, not an actual delete.
A statement is “modifying” if it starts (case-insensitively) with
INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, TRUNCATE,
or MERGE, or if it contains a data-modifying CTE such as
WITH cte AS (UPDATE ...) .... Ferrule tracks parenthesis depth
while scanning; keywords inside subqueries or CTE bodies are still
caught, so WITH t AS (INSERT INTO ...) SELECT * FROM t is correctly
flagged as modifying.
Per-backend output formats
| Backend | --analyze off | --analyze on | Format |
|---|---|---|---|
| PostgreSQL | EXPLAIN (FORMAT JSON, COSTS) … | EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS, TIMING, COSTS) … | JSON |
| MySQL | EXPLAIN FORMAT=JSON … | (same — MySQL EXPLAIN doesn’t execute) | JSON |
| SQLite | EXPLAIN QUERY PLAN … | (same) | Text |
| MSSQL | SET SHOWPLAN_XML ON; … | SET STATISTICS XML ON; … | XML |
| Oracle | EXPLAIN PLAN FOR …; SELECT … FROM TABLE(DBMS_XPLAN.DISPLAY('','','')); | … DBMS_XPLAN.DISPLAY('','','ALLSTATS LAST') | Text |
JSON plans pipe naturally into jq for filtering (Postgres: pull
out node types and costs; MySQL: walk the nested query_block).
The XML and text formats are display-oriented — pretty-print
yourself if you need to compare two plans side by side.
Example
ferrule explain demo \
"SELECT c.id, c.name, COUNT(o.id) AS order_count
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;" \
| jq '.[0]["Plan"]["Node Type"]'
# "HashAggregate"
Dump and Load
ferrule dump exports a table to CSV / JSON / SQL. ferrule load
imports CSV / JSON back into a table.
When to reach for them
- Fixture generation. Pull a sanitized 1000-row sample from prod into a JSON fixture for tests.
- Cross-backend portability. Dump from MySQL, load into
Postgres, no manual schema translation needed (within ferrule’s
unified
Valuetypes). - Snapshots of tables of any size. The implementation fetches
rows in paged batches and formats them incrementally, so memory
usage stays flat (proportional to
batch_size, not table size).
For larger jobs, the native shells win on raw throughput:
pg_dump / pg_restore, psql \copy, mysqldump, bcp. Ferrule
batches but doesn’t attempt to compete with COPY FROM STDIN.
Dump
# CSV (default)
ferrule dump demo customers --dump-format csv > customers.csv
# JSON — preserves types better than CSV
ferrule dump demo customers --dump-format json > customers.json
# SQL INSERT statements — useful for re-importing into a fresh DB
ferrule dump demo customers --dump-format sql > customers.sql
# Schema-qualified table (Postgres / MSSQL)
ferrule dump demo customers --dump-format sql --schema public > customers.sql
# Stream to a file directly with --file
ferrule dump demo customers --dump-format csv --file customers.csv
--dump-formatvs--format. They’re separate.--formatcontrols the display format ferrule uses for stderr / output;--dump-formatcontrols the on-disk dump format. They almost never need to match.
Deterministic dumps
--deterministic (only meaningful with --dump-format sql) produces a
byte-stable, diff-friendly SQL dump:
# Two consecutive dumps produce byte-identical output:
ferrule dump demo customers --dump-format sql --deterministic > a.sql
ferrule dump demo customers --dump-format sql --deterministic > b.sql
diff a.sql b.sql # → no diff
What changes when the flag is set:
- Stable row order. Rows are sorted server-side by the table’s
primary key. Tables without a declared PK emit a
[ferrule] note:on stderr and fall back toORDER BYevery column, lexicographically — correct but slower. - One INSERT per row. The default dump emits a single
INSERT INTO t (...) VALUES (...), (...), ...;per batch. With--deterministic, each row becomes its own statement, so a single row change diffs as a single-line edit. - Sorted JSON keys. JSON cells are re-serialised with object keys
in lexicographic order so Postgres
JSONB(hash-ordered) and MySQLJSON(insertion-ordered) produce identical output.
For arbitrary --query (Wave 2 follow-up), the dump path refuses to
run with --deterministic unless the source SQL contains an
ORDER BY clause — the substring match is intentionally pragmatic
(it accepts false positives in comments and string literals) to keep
the check parser-free.
Out of scope (filed as [P9b] follow-up): CREATE TABLE synthesis —
--deterministic today emits only the INSERT stream, so the target
schema must exist before re-loading.
Load
# CSV — column order from the first row
ferrule load demo events.csv --table events
# JSON array of objects — column names from object keys
ferrule load demo events.json --table events
# Format inferred from the file extension
ferrule load demo events.csv # CSV inferred
ferrule load demo events.json # JSON inferred
# Override
ferrule load demo data --table events --format json
--create-table (JSON only)
When the target table doesn’t exist, JSON loads can infer a schema from the first record:
ferrule load demo new_events.json --table events --create-table
The first object in the JSON array becomes the schema template:
- Keys → column names.
- Number values →
Int64orFloat64depending on whether the literal contains a decimal point. - Boolean values →
Bool. - String values →
String. null→ nullable column with type from a later record (best effort; objects with all-null first records fail).
This is “good enough for fixtures.” For production schemas, write
the CREATE TABLE by hand.
Behavior notes
- Insertion is batched (default ~1000 rows per round-trip).
- Errors abort the load. Ferrule does not transactionally roll back already-loaded rows — be ready for partial state if a malformed record sneaks in mid-file.
- No type coercion. A CSV with
"true"in aBoolcolumn is fine; a CSV with"yes"is not. Pre-clean upstream.
Export
ferrule export streams the result of an arbitrary SQL query directly to
a file in CSV, JSON, JSONL, or SQL INSERT format. Unlike dump, which
only handles single tables, export works with any SELECT statement.
Basic usage
ferrule export demo "SELECT * FROM events" --file events.csv
ferrule export demo "SELECT * FROM events" --format json --file events.json
ferrule export demo "SELECT * FROM events" --format jsonl
Page-size is tuned for the backend; it fetches rows in chunks to keep memory usage flat.
ferrule export demo "SELECT * FROM events" --page-size 5000 --file events.csv
Formats
| Format | Description |
|---|---|
csv | Comma-separated values; newlines inside strings are escaped |
json | One JSON array of objects |
jsonl | One JSON object per line |
sql | INSERT INTO ... VALUES (...) statements |
Notes
--fileis optional; without it, the result goes to stdout.--page-sizedefaults to 1000 and controls the server-side chunk size. Use--page-size 0to disable paging (not recommended for huge result sets).--limitand--offsetare respected just likequery.
Watch mode
Re-execute a query at fixed intervals or whenever a watched file changes.
When it’s useful
- Watching a job queue drain.
- Verifying a deploy that changes a counter.
- Polling a long-running migration’s progress.
- Smoke-testing during incident response without typing
\!\!inpsqlover and over. - Re-running a query as you edit a
.sqlfile in your editor.
Basic usage
# Re-runs every 5 seconds until you Ctrl-C
ferrule watch demo "SELECT COUNT(*) FROM events;"
# Faster cadence
ferrule watch demo "SELECT COUNT(*) FROM events;" --interval 1
# Bounded run — exits after 10 iterations
ferrule watch demo "SELECT NOW();" --interval 1 --max-iterations 10
--interval is in seconds; the minimum useful value is 1 (the
backend round-trip dominates anything tighter).
query --watch — shorthand
You can also use --watch on query itself, which delegates to the
same watch loop:
ferrule query demo "SELECT COUNT(*) FROM events;" --watch
ferrule query demo "SELECT COUNT(*) FROM events;" --watch --watch-interval 2
This is identical to ferrule watch with the same arguments; it’s
just a convenience when you start with a query and realize you want
to keep polling it.
--file-path — watch a file for changes
Instead of polling on an interval, trigger re-execution whenever a file changes on disk:
ferrule watch demo --file-path ./query.sql
The SQL is re-read from the file every time the filesystem watcher fires (with a 100ms debounce). Use this when you’re editing the query in an editor and want ferrule to re-run it every time you save.
--diff — only print on change
Without --diff, watch prints a header and the full result on every
iteration. With --diff, it suppresses output when the result is
identical to the previous one:
ferrule watch demo "SELECT COUNT(*) FROM events;" --interval 2 --diff
That makes it trivial to leave a watch --diff running in a corner
of your tmux session and only get noise when something actually
changes.
--exit-on-error — fail fast
By default, watch logs connection or query errors to stderr and
keeps polling. With --exit-on-error, the command terminates on
the first failure:
ferrule watch demo "SELECT COUNT(*) FROM events;" --exit-on-error
Useful in CI pipelines or wrapper scripts where a broken connection should stop the job immediately rather than spamming errors forever.
--bell — terminal bell on change
When paired with --diff, rings the terminal bell (ASCII BEL, \x07)
whenever the output changes:
ferrule watch demo "SELECT COUNT(*) FROM events;" --diff --bell
Pair with a terminal that flashes the window on bell (e.g. iTerm2 “Flash visual bell”) to get passive attention while you work elsewhere.
Notes
- Ctrl-C exits cleanly — the in-flight query is allowed to finish.
- The backend connection is reused across iterations, so cost-per- iteration is roughly one round-trip plus the query itself. Pair with the connection-pooling daemon for tight loops.
- Use
--format rawor--format jsonfor clean output that’s easy to diff visually or pipe into another tool.
Connection Pooling Daemon
Most ferrule invocations are one-shot: spin up the runtime, open a connection, run one query, tear down. For occasional use that’s fine — a few hundred milliseconds of connection setup disappears into the noise of a human typing. Tight scripts that run hundreds of queries in a row pay that cost on every call.
The daemon is a long-lived background process that holds open connections so subsequent commands reuse them.
When to use it
Reach for --daemon when:
- A script (or pipeline, or dashboard refresh) issues many small queries to the same database in quick succession.
- You’re running an interactive REPL workflow and the connect time is noticeable.
- The database itself is slow to authenticate (Oracle in particular takes a few seconds to set up a session).
Skip it when:
- You only run ferrule once or twice per shell session — the per-invocation overhead is tiny, and the daemon is memory you won’t reclaim.
- You’re on a shared multi-user host. The daemon is per-user and doesn’t help anyone but you.
- You can’t afford the daemon to hold a resolved password in memory (see Security caveats).
Lifecycle
Start
# Foreground (good for debugging — Ctrl-C to stop)
ferrule conn start
# Background — forks and detaches
ferrule conn start --background
On Unix, the daemon binds a per-user socket inside the user cache directory; on Windows it listens on a random localhost TCP port recorded in a sibling file:
| OS | Endpoint | Discovery file |
|---|---|---|
| Linux | ~/.cache/ferrule/daemon.sock (Unix domain socket, mode 0600) | (the socket itself) |
| macOS | ~/Library/Caches/ferrule/daemon.sock (Unix domain socket, mode 0600) | (the socket itself) |
| Windows | 127.0.0.1:<random-port> (TCP, localhost only) | %LOCALAPPDATA%\ferrule\daemon.port |
The PID is recorded next to the socket at daemon.pid so
ferrule conn status can find it. Unix socket permissions are
0600, restricting access to the same user.
Status
ferrule conn status
Reports whether the daemon is running, its PID, and how many connections it currently holds.
Stop
ferrule conn stop
Sends a graceful shutdown. In-flight queries are allowed to finish; new requests are rejected.
Restart
ferrule conn restart
Equivalent to stop followed by start (foreground). Useful after
changing config that affects how connections are built (TLS posture,
default profile defaults).
Routing requests through the daemon
Every command that takes a connection accepts --daemon:
ferrule query prod "SELECT * FROM users LIMIT 10" --daemon
ferrule tables prod --daemon
ferrule describe prod users --daemon
ferrule explain prod "SELECT * FROM users WHERE id = 1" --daemon
ferrule dump prod users --dump-format csv --daemon > users.csv
ferrule watch prod "SELECT COUNT(*) FROM jobs" --interval 2 --daemon
The first request to a given connection name opens a real connection and pools it. Subsequent requests reuse it. Pool keys are based on the resolved URL (post-credential-stack), so two profiles pointing at the same DB don’t waste a slot.
If the daemon isn’t running, --daemon exits with a usage error
listing how to start it. There’s no implicit “start if missing”
because that would mask configuration mistakes.
Operational notes
- No persistent state. Pools live in the daemon’s RAM. A restart drops everything; the next request rebuilds.
- No remote endpoint. The daemon listens on a per-user Unix socket, never on TCP. There is no flag to expose it over the network — that’s intentional.
- Connections live for the daemon’s lifetime. There is no idle reaper today; pooled connections stay open until they’re either used (refreshing the last-used timestamp) or the daemon is stopped / restarted. If a server-side timeout closes the connection out from under the pool, the next request reopens it transparently.
- One daemon per user, not per database. Each user gets one daemon process that multiplexes all their connections. Two ferrule users on the same box have independent daemons and independent sockets.
Troubleshooting
daemon is not running
ferrule conn start --background and try again. Confirm with
ferrule conn status.
failed to connect to daemon socket
The socket exists but the daemon isn’t reachable. Often happens
after a clean reboot if --background left a stale socket file.
ferrule conn restart clears it.
Stale connections after DB restart
The daemon doesn’t proactively detect that the DB went away. The
first reused connection after a server restart will fail; the
second one (built fresh) succeeds. If you see a single transient
failure after a known DB blip, that’s why. ferrule conn restart
forces all pools to drop.
Want to see what it’s doing
Run the daemon in the foreground:
ferrule conn start
Logs to stderr in human-readable form. Combine with
RUST_LOG=ferrule=debug for verbose output.
See also: Concepts, Connections, Troubleshooting.
Filtering Results with JMESPath
ferrule query --filter <expr> runs a JMESPath expression over the
JSON output of a query before printing it. It’s the same query
language aws --query uses, so anyone who’s poked at AWS CLI output
will recognize the shape.
The filter operates on the rendered JSON — the same structure you
get from --format json — not on the SQL result set. That means you
can use it to reshape, reduce, or summarize what the database
returned, without rewriting the SQL.
When to use it
- You want a count, sum, or max from a query that returned rows, but
you don’t want to write the aggregate in SQL — maybe because the
rows are already cached, or because you’re piping through
jq-like follow-up filters. - You want to extract a single column from a SELECT and feed it into
the next shell command, without
awk-ing CSV. - You want to reshape the output as objects-of-objects or arrays-of-strings without touching the SQL.
--filter is not a substitute for a WHERE clause. The query
still pulls every row across the wire; the filter just trims the
output. Push the predicate into SQL when the row count is large.
Implies --format json
--filter and JSON go together. Passing --format table with a
filter is rejected up front:
$ ferrule query demo "SELECT * FROM users" --filter "[*].name" --format table
ferrule: error: --filter requires --format json (got --format table)
If you don’t pass --format at all, ferrule treats the filter as
implicitly setting JSON. Useful when piping output:
ferrule query demo "SELECT id, name FROM users" --filter "[*].name" \
| jq -r '.[]' \
| head -5
Examples
Pull a single column as an array of strings:
ferrule query demo "SELECT name, email FROM users" \
--filter "[*].name"
# → ["Alice", "Bob", "Charlie"]
Filter rows where a field matches:
ferrule query demo "SELECT id, name, role FROM users" \
--filter "[?role=='admin'].name"
# → ["Alice"]
Reshape into a map keyed by name:
ferrule query demo "SELECT name, age FROM users" \
--filter "{by_name: [*].{name: name, age: age}}"
Count rows that match a predicate:
ferrule query demo "SELECT id, active FROM users" \
--filter "length([?active])"
# → 7
Take the first row:
ferrule query demo "SELECT * FROM users ORDER BY created_at DESC LIMIT 1" \
--filter "[0]"
Scope and limits
| Combination | Result |
|---|---|
--filter + single SELECT result | Filter runs on the result rows array |
--filter + INSERT/UPDATE/DELETE (summary) | Rejected — “requires a SELECT-style query that returns rows” |
--filter + multi-statement | Rejected — “cannot be applied to multi-statement queries” |
--filter + --explain | Rejected — explain payloads are XML/text/JSON-of-plan, not row data |
| Invalid JMESPath syntax | Exits with code 4 (query error class) and a clear diagnostic |
The output is always re-serialized as pretty-printed JSON (two-space
indentation), so the result is human-readable AND pipe-friendly for
jq/yq/fx.
Filter language quick reference
A few JMESPath patterns ferrule users hit most often. The full language is much larger — see the JMESPath tutorial.
| Pattern | Meaning |
|---|---|
[*].field | Project field from every element of the array |
[?field=='X'] | Filter elements where field == 'X' |
[?age > \30`]` | Filter where age > 30 (note backticks for literals) |
length(@) | Length of the current node |
sort_by(@, &field) | Sort array by a field |
[0:5] | First five elements |
{a: x, b: y} | Build a new object literal |
@ | The current node (whole result) |
Exit codes
0— filter ran successfully and produced output (even if the filtered output is an empty array[]).4— JMESPath parse error, JMESPath evaluation error, or output could not be re-serialized as JSON. Same class as a SQL query failure.
Schema Diff
ferrule diff <conn-a> <conn-b> compares the table-and-column
inventory of two databases and prints the differences. Like git diff for schemas — useful when migrations have been applied
out-of-order, when staging and prod have drifted, or when porting a
schema between backends.
What it compares
For each table that exists on both sides:
- Columns present only in A (
-lines). - Columns present only in B (
+lines). - Columns whose
data_typediffers across the two (~lines).
For tables that exist on only one side:
- Listed under
Tables only in AorTables only in B.
The comparison is keyed on table and column names. Type comparison
uses the data_type string from each backend’s
information_schema.columns (or its equivalent), so be aware: a
VARCHAR(255) on one side and TEXT on the other will show as a
type change, even though they often behave identically.
Cross-backend comparison
The intentional design call: ferrule does not normalize data types
across backends. Diffing Postgres → MySQL will produce a lot of ~
lines because their type vocabularies are different. Diffing Postgres
→ Postgres or MySQL → MySQL is where this command shines.
If you need cross-backend type compatibility, post-process the JSON
output (see --format json below) and apply your own type-mapping
rules.
Basic usage
# Two named connections from .ferrule.toml
ferrule diff staging prod
# Two raw URLs
ferrule diff \
"postgres://app:pwd@staging-db/myapp" \
"postgres://app:pwd@prod-db/myapp"
# Mix: name on the left, URL on the right
ferrule diff staging "postgres://app:pwd@prod-db/myapp"
Diffing a single table
--table <name> restricts the diff to one table on each side. Useful
when the inventory is large and you only care about one subject.
ferrule diff staging prod --table users
If the table is missing on either side, the command exits with a clear message rather than an empty diff.
Output formats
The default is human-readable text, mirroring diff output:
Tables only in A:
- audit_log
Tables only in B:
+ feature_flags
Table users:
- middle_name VARCHAR(255)
+ nickname TEXT
~ created_at: timestamp -> timestamp with time zone
JSON output (--format json) is structured — a top-level object with
tables_only_in_a, tables_only_in_b, and table_diffs arrays.
Pipe through jq for arbitrary post-processing:
ferrule diff staging prod --format json \
| jq '.table_diffs[] | select(.table == "users")'
CSV output (--format csv) flattens the diff into rows: one per
column-level change, with columns table, change_type, column,
a_type, b_type. Easy to import into a spreadsheet for review.
Per-side passwords
The -p / --password flag from query doesn’t apply here — diff
takes two connections, so each side gets its own flag:
--password-a <pwd>— overrides the credential stack for connection A.--password-b <pwd>— same for B.
If a password isn’t passed on the CLI and isn’t in the credential
stack, ferrule falls through to the standard interactive prompt
twice (once per side). For non-interactive contexts, use
FERRULE_<NAME>_PASSWORD env vars or the OS keyring.
Exit codes
ferrule diff follows GNU diff convention:
0— schemas are identical (no differences in tables or columns).1— differences were found. Suitable for CI gates: a non-zero exit makes the job fail when staging and prod drift.2— usage error (bad arguments, unknown connection name).3— connection error (TLS, auth, network — couldn’t reach a side).4— query / introspection error.
Exit 1 is reserved across ferrule for “command succeeded with a
notable result” — diff today, future --fail-on-empty and check /
validate commands tomorrow. Real errors live at codes 2..=4, so a
diff-found result is unambiguously distinguishable from a malformed
invocation.
Wrapping into CI
A common pattern: run ferrule diff in CI against the production
database and the migration target, fail the job on any drift.
#!/bin/sh
set -e
if ! ferrule diff prod migration-target --format json > diff.json; then
echo "Schemas drifted:"
jq . diff.json
exit 1
fi
echo "Schemas match — safe to deploy."
The JSON output is stable across runs, so the job’s diagnostic stays attached to the same artifact.
Limits
- No constraint, index, or trigger comparison. Only column inventory and types. PRIMARY KEY / FOREIGN KEY differences are invisible here. A future Wave is planned to extend this.
- No row-data comparison. This is a structure diff only. For
row-level diffs, dump both tables and use
diff/git diff/ custom tooling. - Type strings are compared verbatim.
varchar(255)andcharacter varying(255)will show as different, even though they are the same type — the underlyingdata_typecolumn reports them differently across backends.
Cross-DB Copy
ferrule copy <SRC> <DST> streams rows from one database into another,
translating column types via ferrule’s unified Value enum. Postgres
→ SQLite snapshots, MySQL → MSSQL exports, anything → anything — one
command, no intermediate file, no third tool.
What ships in v1
| Capability | Status | Notes |
|---|---|---|
| Per-backend type translation | Shipped | See “Type translation” below |
| Generic multi-row INSERT | Shipped | Default; portable across all five backends |
| Native bulk paths | Shipped | --bulk-native auto|on; PG / MSSQL / MySQL / Oracle |
error / append / truncate | Shipped | --if-exists; truncate requires --yes from a TTY |
skip / upsert | Shipped | --if-exists; PK-driven, force generic path |
| Schema-level copy (FK-ordered) | Shipped | --all-tables with --include / --exclude / --no-fk-check |
Postgres binary COPY | Shipped | --copy-format binary; PG-only |
| Composite-key / unique-index override | Shipped | --key COL[,COL...]; closes #43 |
Preserve source PK in --create-table | Shipped | --preserve-pk; closes #45 |
Per-side --src-* / --dst-* flags | Shipped | SSH / proxy / key / insecure; closes #44 |
Daemon routing for copy | Deferred | Tracked under #10 |
Parallel loader (--parallel N) | Deferred | Depends on the above; tracked under #38 |
Oracle direct-path INSERT /*+ APPEND */ | Deferred | Tracked under #37 |
--bulk-native default flip to auto | Deferred | Tracked under #39; waiting one release cycle |
What it does
Source and destination can be any pair of supported backends (Postgres, MySQL, MSSQL, SQLite, opt-in Oracle). For each batch:
- SELECT one page from the source, paged via
LIMIT/OFFSETor the dialect equivalent (same machinery asferrule dump). - Translate the source column types into a target-side DDL when
--create-tableis set (see “Type translation” below). - INSERT the page into the destination, with per-backend literal
quoting from
ferrule_core::params::render_value.
The first SELECT establishes the column shape. Subsequent SELECTs keep paging until a partial page is returned.
Conflict handling
The default is non-destructive: a copy into a non-empty existing target table errors out before any INSERT runs (and before the source SELECT is even issued).
| Target state | Default behavior |
|---|---|
Doesn’t exist + --create-table | Create + insert |
Doesn’t exist, no --create-table | Usage error (exit 2) |
| Exists, empty | Insert (treated as fresh) |
| Exists, non-empty | Error (exit 4) with hint to pass --if-exists |
Override the default with --if-exists <strategy>:
error(default) — refuse if target is non-empty. Source is never touched.append— INSERT alongside existing rows. UNIQUE / PK conflicts surface as driver errors and abort the run.truncate—DELETE FROM <tbl>then INSERT. Destructive, requires--yeswhen stdin is a TTY. The DELETE and the first batch run inside the same transaction so a transient first-INSERT failure cannot leave the target wiped + empty.skip— INSERT new rows; silently drop rows whose primary key already exists on the destination. Per-backend codegen:- PG / SQLite:
INSERT … ON CONFLICT (pk) DO NOTHING - MySQL:
INSERT IGNORE INTO … - MSSQL / Oracle:
MERGE … WHEN NOT MATCHED THEN INSERT
- PG / SQLite:
upsert— INSERT new rows; UPDATE every non-PK column when the primary key already exists. Per-backend codegen:- PG / SQLite:
INSERT … ON CONFLICT (pk) DO UPDATE SET col = EXCLUDED.col, … - MySQL:
INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col), … - MSSQL / Oracle: full
MERGEwith bothWHEN MATCHED THEN UPDATEandWHEN NOT MATCHED THEN INSERTbranches.
- PG / SQLite:
skip and upsert need conflict columns. Three ways to supply them,
checked in this order:
--key COL[,COL...]— explicit user-supplied list (repeatable or comma-separated). Useful for tables with no declared PK and for keying upsert on a unique index that isn’t the PK. Names are validated against the source SELECT shape; a typo fails fast before any INSERT runs.--keyis ignored (with a one-line stderr notice) for non-conflict strategies.- Destination’s declared primary key — auto-detected via
Connection::primary_keywhen--keyis absent. - Otherwise hard error before the source SELECT runs, listing the
three escape hatches: declare a PK on the destination, run
--create-table --preserve-pk, or pass--key COL[,COL...].
Cross-backend copies may need an explicit SELECT col AS "DEST_NAME" …
alias when source and destination disagree on identifier case (Oracle
uppercases unquoted identifiers).
Preserving the source PK in --create-table
--create-table is data-movement, not schema migration — it emits
column types only. That collides with --if-exists skip|upsert,
which needs a declared PK on the destination. Pass --preserve-pk
alongside --create-table to lift the source table’s declared PK
into the emitted DDL via a PRIMARY KEY (...) clause:
# Snapshot prod into dev with refresh-able PKs.
ferrule copy prod-pg snap-sqlite --table users --create-table --preserve-pk
# Subsequent refresh keys on the lifted PK:
ferrule copy prod-pg snap-sqlite --table users --if-exists upsert
Best-effort: source tables with no declared PK fall through to the v1
column-only DDL. --preserve-pk is ignored in --query mode (no
canonical source table to inspect). --preserve-pk requires
--create-table. Indexes, defaults, and check constraints are still
not copied — full DDL fidelity remains ferrule diff / ferrule migrate territory.
Conflict resolution always runs through the generic INSERT path: the
native bulk loaders (COPY, BULK INSERT, LOAD DATA, Batch) carry
no MERGE / ON CONFLICT semantics. Passing --bulk-native=auto or
--bulk-native=on alongside --if-exists skip|upsert emits a one-line
stderr notice and silently degrades the bulk path for that copy.
Atomicity
By default each batch (--batch, default 1000) is committed
independently. Progress survives mid-copy failure; partial state is
visible on the target. This matches ferrule load’s semantics.
--atomic wraps the entire copy in a single target-side transaction.
Recommended for snapshots; avoid for million-row migrations because
the target holds locks for the full duration.
Examples
# Snapshot prod Postgres into a local SQLite file.
ferrule copy \
"postgres://ferrule:ferrule@127.0.0.1:15432/ferrule?sslmode=disable" \
"sqlite:///tmp/snap.db" \
--table test_users --create-table
# Refresh: clear the destination and reload (interactive confirmation).
ferrule copy prod-pg snap-sqlite --table test_users --if-exists truncate --yes
# Project a subset via --query.
ferrule copy prod-mysql warehouse-mssql \
--query "SELECT id, name FROM users WHERE active = 1" \
--into active_users --create-table
# Schema-level refresh: copy every table from prod into a fresh
# SQLite snapshot, FK-ordered. --include narrows the selection;
# --exclude trims the noise.
ferrule copy prod-pg snap-sqlite --all-tables --create-table \
--include 'app_*' --exclude '*_audit'
# Atomic snapshot, all-or-nothing on the target.
ferrule copy prod-pg snap-sqlite \
--table test_users --create-table --atomic
Type translation
When --create-table is set, ferrule issues a
CREATE TABLE IF NOT EXISTS against the destination, mapping each
source column’s TypeHint to a destination type:
TypeHint | Postgres | MySQL | MSSQL | SQLite | Oracle |
|---|---|---|---|---|---|
Bool | BOOLEAN | TINYINT(1) | BIT | INTEGER | NUMBER(1) |
Int64 | BIGINT | BIGINT | BIGINT | INTEGER | NUMBER(19) |
Float64 | DOUBLE PRECISION | DOUBLE | FLOAT | REAL | BINARY_DOUBLE |
Decimal | NUMERIC | DECIMAL(38,10) | DECIMAL(38,10) | NUMERIC | NUMBER |
String | TEXT | TEXT | NVARCHAR(MAX) | TEXT | CLOB |
Bytes | BYTEA | LONGBLOB | VARBINARY(MAX) | BLOB | BLOB |
Date | DATE | DATE | DATE | TEXT | DATE |
Time | TIME | TIME | TIME | TEXT | TIMESTAMP |
DateTime | TIMESTAMP | DATETIME | DATETIME2 | TEXT | TIMESTAMP |
DateTimeTz | TIMESTAMPTZ | DATETIME | DATETIMEOFFSET | TEXT | TIMESTAMP WITH TZ |
Json | JSONB | JSON | NVARCHAR(MAX) | TEXT | CLOB |
Uuid | UUID | CHAR(36) | UNIQUEIDENTIFIER | TEXT | RAW(16) |
Array | JSONB | JSON | NVARCHAR(MAX) | TEXT | CLOB |
The mapping favours portability over fidelity. Decimal collapses to
(38,10) precision on backends that need it; Array is stored as
JSON-ish text on every backend except Postgres / MySQL where the
native type carries it. SQLite uses dynamic typing, so most types
collapse to its five storage classes.
NOT NULL is preserved per source column metadata. Primary keys,
indexes, defaults, and check constraints are not copied — --create-table
focuses on data movement, not schema migration. For full DDL fidelity
use ferrule diff / ferrule migrate, or restore from a pg_dump /
mysqldump.
Native bulk paths (--bulk-native)
By default, ferrule copy builds one multi-row INSERT per page and
sends it via the standard driver path. That works everywhere but is
5–50× slower than each backend’s native bulk loader for
million-row migrations.
Pass --bulk-native <mode> to opt into the native path:
| Mode | Behavior |
|---|---|
off | (default) Always use the generic multi-row INSERT path. v1 baseline. |
auto | Try the native path; on BulkUnavailable emit one stderr warning and fall back to generic INSERT for that batch. |
on | Require the native path. BulkUnavailable surfaces as a hard error referencing --bulk-native. |
The flag is destination-only: each backend ships a native path if it has one. SQLite stays on the generic path always (its bottleneck is fsync, not parse/plan).
| Destination | Native path | Common BulkUnavailable triggers |
|---|---|---|
| Postgres | COPY <tbl> (cols) FROM STDIN WITH (FORMAT TEXT | BINARY) | Target is a VIEW / materialized view / foreign table; --copy-format binary against a column with TypeHint::Other (rare). |
| MSSQL | tiberius::Client::bulk_insert (TDS bulk-load token) | Target is not a base table; Invalid object name. |
| MySQL | LOAD DATA LOCAL INFILE via a per-call infile handler | Server-side local_infile=OFF (default in MySQL 8.0+ — set local_infile=ON to enable). |
| Oracle | oracle::Batch (array DML via ODPI-C) | ORA-01031 insufficient privilege; ORA-00942 table does not exist; Instant Client missing. |
| SQLite | No native loader. --bulk-native=on returns a hard error. | — |
Format choices
-
Postgres defaults to
FORMAT TEXT. Pass--copy-format binaryto opt intoFORMAT BINARY(Postgres-only flag; ignored elsewhere). Binary streams viatokio_postgres::binary_copy::BinaryCopyInWriter; each value is bound through itsToSqlimpl using the destination PG type derived from the source column’sTypeHint. The mapping matches--create-table’s DDL translator, so a binary copy into a table created by ferrule round-trips cleanly.When to pick which:
- Binary wins on
BIGINT/TIMESTAMPTZ/UUID/NUMERIC- heavy schemas, where text parse cost dominates. - Text is faster (or at-worst even) on
TEXT/JSONB/BYTEA-heavy schemas, because the typed 4-byte length prefix binary frames each value with inflates small payloads beyond their tab-separated equivalent. - Binary requires
--bulk-native=auto|on; the generic INSERT path doesn’t use COPY at all. Passing--copy-format binary --bulk-native offis a usage error. - Source columns whose
TypeHintisOther(rare; arises when a backend driver can’t classify a custom type) cannot be bound in binary mode and surface asBulkUnavailableso the dispatcher can fall back under--bulk-native=auto.
- Binary wins on
-
MySQL ships UTF-8 tab/newline-delimited with backslash escapes matching
ESCAPED BY '\\'. The per-call local infile handler is installed only for the duration of onebulk_insert_rowscall — a hostileLOAD DATA LOCAL INFILE '/etc/passwd'typed intoferrule queryon the same connection fails immediately because there is no handler installed at that moment. -
Oracle uses array DML (safe default — respects triggers, constraints, indexes). The faster but more invasive
INSERT /*+ APPEND */direct-path is opt-in and tracked under issue #37; on this path bulk loads bypass triggers and require exclusive locks until commit.
What surfaces in --bulk-native=auto
A successful bulk batch in auto mode prints nothing extra. On
fallback, you’ll see one stderr line per affected batch:
[ferrule] bulk: <backend> path unavailable: <reason>; using generic INSERT
Use --verbose to additionally log one line per successful bulk batch.
Schema-level copy (--all-tables)
ferrule copy <SRC> <DST> --all-tables discovers every table on the
source, orders them so foreign-key parents load before children, and
copies each one through the same per-table pipeline --table uses.
This is the canonical “refresh dev from prod” workflow.
ferrule copy prod-pg snap-sqlite --all-tables --create-table
ferrule copy prod-pg snap-sqlite --all-tables --if-exists truncate --yes
Filtering
Two repeatable glob flags narrow which tables get copied. Globs use
shell-style * and ?, matched case-sensitively against the
identifier shape the source returns:
# Only app_* tables, skipping the *_audit log shadows.
ferrule copy prod-pg snap-sqlite --all-tables --create-table \
--include 'app_*' --exclude '*_audit'
--includedefaults to “everything”; multiple--includepatterns OR together.--excludeis always applied after the include filter.- Tables referenced by foreign keys but excluded from the selection are simply dropped from the dependency graph — the copy will not block waiting for them.
Order, cycles, and --no-fk-check
The load order is computed via Kahn’s algorithm over the
destination-aware view of the FK graph (Connection::list_foreign_keys).
Independent tables retain their list_tables order so successive
runs are deterministic.
Foreign-key cycles hard-error before any copy starts, with the
cycle path in the message. Pass --no-fk-check to copy in
discovery order anyway — useful when:
- the destination has deferrable FKs (Postgres
DEFERRABLE INITIALLY DEFERRED) or FK enforcement is off (SQLite default;SET session_replication_role = replicaon Postgres); - you plan to drop and recreate constraints after the copy;
- the cycle is across self-referential columns you’ll backfill later.
Self-referential foreign keys (tree.parent_id REFERENCES tree(id))
do not count as cycles and are loaded in a single pass.
Per-table progress
With --verbose, ferrule prints one line at the start and end of
each table:
[ferrule] [3/12] copying users…
[ferrule] [3/12] users: 4523 rows
Combining strategies
--all-tables honours every other copy flag:
--if-exists truncate --yesclears each destination table once at the top of its own copy —--yesis consulted once for the run, not per table.--if-exists skip|upsertrequires a PK per table; tables without a PK hard-error at their own step (see [Conflict handling] (#conflict-handling)).--bulk-native auto|onapplies per table;skip|upsertstill force the generic path table-by-table.--atomicwraps each table in its own transaction. A cross-table single transaction is not in this release (it would require deferrable FK support on the destination).
Per-side connection flags
The shared connection flags (--ssh-tunnel, --ssh-key,
--proxy-url, --insecure) apply to both sides by default. For the
realistic case where exactly one side needs a tunnel — e.g. SSH into a
bastion to reach prod, then write to a local-network warehouse — pass
the corresponding --src-* or --dst-* override:
| Shared | Source-only | Destination-only |
|---|---|---|
--ssh-tunnel | --src-ssh-tunnel | --dst-ssh-tunnel |
--ssh-key | --src-ssh-key | --dst-ssh-key |
--proxy-url | --src-proxy-url | --dst-proxy-url |
--insecure | --src-insecure | --dst-insecure |
Resolution: per-side override > unsuffixed shared > profile defaults
in .ferrule.toml. Setting both the shared and a per-side form for
the same field is a usage error (exit 2) — no silent merge.
# SSH into a bastion to reach prod Postgres; write to a local-
# network SQLite warehouse without a tunnel.
ferrule copy \
--src-ssh-tunnel ferrule@bastion.prod --src-ssh-key ~/.ssh/id_ed25519 \
prod-pg /tmp/snap.db \
--table users --create-table --preserve-pk
--daemon is not per-side: the connection pool either runs or doesn’t.
check_daemon_ssh_compat continues to reject daemon + SSH on either
side; the resolved per-side tunnel config is consulted independently.
Known limits
The “Deferred” rows in the v1 matrix above are
the explicit known limits — daemon-routed copy (#10), parallel
multi-table fan-out (#38), Oracle direct-path (#37), and the
--bulk-native default flip (#39). Each links to a GitHub issue with
the rationale and follow-up plan.
SSH Tunnels
Many production databases sit behind a bastion: the database refuses
direct connections from the open internet, but you can ssh into a
jumphost on the inside that can reach it. Most desktop database
tools (DBeaver, DataGrip, Beekeeper, TablePlus) handle this with a
“use SSH tunnel” checkbox; ferrule does the same with a CLI flag.
The tunnel is set up with russh (a pure-Rust SSH 2 client),
opens a direct-tcpip channel to the database, and passes the
underlying stream into the database driver. There is no ssh binary
shelled out to and no ~/.ssh/config honored.
Quick start
ferrule query \
--ssh-tunnel ec2-user@bastion.example.com \
--ssh-key ~/.ssh/id_ed25519 \
"postgres://app:pwd@db.internal:5432/myapp" \
"SELECT * FROM users LIMIT 10;"
This:
- Opens an SSH session to
ec2-user@bastion.example.com:22using the key at~/.ssh/id_ed25519. - Asks the bastion to open a
direct-tcpipchannel todb.internal:5432. - Hands that channel directly to
tokio_postgresviaconnect_raw. The Postgres protocol — and TLS, if your URL hassslmode=require— runs end-to-end through the SSH stream.
The database URL stays clean: it’s the same string you’d copy out of the AWS RDS, GCP Cloud SQL, or Heroku console. SSH config goes in its own flags or its own profile keys.
Where SSH config lives
Three layers, primary to ad-hoc:
1. Profile keys (primary — .ferrule.toml)
For a connection you use repeatedly, put the SSH bits in the profile:
[connection.prod-pg]
url = "postgres://app:pwd@db.internal:5432/myapp"
ssh_host = "bastion.example.com"
ssh_user = "ec2-user"
ssh_port = 22
ssh_key = "~/.ssh/prod-bastion.pem"
Then ferrule query prod-pg "SELECT 1" automatically tunnels
through ec2-user@bastion.example.com:22 using the named key.
2. CLI flags (ad-hoc)
For one-shot use against a connection you haven’t profiled:
--ssh-tunnel [user@]host[:port]— atomic-replacement for the three SSH connection parameters. Matches pgcli’s flag syntax verbatim. If you pass--ssh-tunnel host(no user, no port), the user falls back to$USERand the port falls back to 22 — not to whatever the profile said. “One flag, one tunnel target.”--ssh-key <path>— overridesssh_keyindependently. Useful for testing different keys against the same bastion.
3. The URL stays plain
There is no ssh+postgres:// scheme. That style was tried, then
backed out — every other tool in the ecosystem (DBeaver, DataGrip,
Beekeeper, TablePlus, Sequel Ace, pgAdmin, MySQL Workbench, Navicat,
SQLAlchemy, Prisma, TypeORM, libpq pg_service.conf, pgcli) keeps
the SSH section separate from the database URL, and ferrule matches
that consensus. The same postgres://... string works inside or
outside the tunnel.
Key resolution
When you pass --ssh-tunnel ... (or set ssh_host in a profile),
ferrule resolves the SSH key in this order. First hit wins:
--ssh-key <path>(CLI) orssh_key = "..."(profile).FERRULE_<NAME>_SSH_KEY=<path>env var (where<NAME>is the uppercased connection name with-→_).~/.ssh/id_ed25519.~/.ssh/id_rsa.- The SSH agent at
$SSH_AUTH_SOCK.
If none of those resolve, ferrule errors out with a diagnostic listing every option it tried — the same shape as the password resolution stack.
no SSH key resolved for connection 'prod-pg'. Provide one of:
--ssh-key <path>
ssh_key in the profile
FERRULE_PROD_PG_SSH_KEY=<path> env var
~/.ssh/id_ed25519 or ~/.ssh/id_rsa identity file
a running SSH agent (SSH_AUTH_SOCK)
Encrypted keys
If load_secret_key reports the key needs a passphrase, ferrule
prompts for it interactively:
Enter passphrase for SSH key /home/user/.ssh/id_ed25519:
In non-interactive contexts (CI, scripts, pipes) the prompt is skipped and ferrule returns an error:
SSH key /path/to/key is encrypted. Passphrase prompting requires an interactive terminal.
Use an SSH agent or decrypt the key on disk.
Workarounds when a terminal is not available:
- Use the SSH agent.
ssh-add ~/.ssh/encrypted-keyonce per shell session, then ferrule will route signing requests through the agent. - Decrypt the key on disk:
ssh-keygen -p -f ~/.ssh/encrypted-keyremoves the passphrase (don’t do this for keys you don’t exclusively control).
Transport: how the bytes flow
Two transports, picked by backend automatically:
(b) Direct stream — Postgres
The russh ChannelStream is fed straight into
tokio_postgres::Config::connect_raw(stream, tls_connector). There
is no extra TCP hop on the local side. TLS, if requested via
?sslmode=require/verify-full, is negotiated end-to-end inside
the SSH channel — so a URL like
postgres://app:pwd@db/myapp?sslmode=require paired with
--ssh-tunnel bastion gets BOTH SSH transport AND TLS to the
database. The two layers compose.
(a) Local listener — MySQL, MSSQL, Oracle
Those drivers don’t expose a custom-stream injection API, so ferrule
binds a 127.0.0.1:<random> TCP listener, spawns a forwarder task
that pumps bytes between the listener and the SSH channel via
tokio::io::copy_bidirectional, and rewrites the URL to point at
the local port before handing it to the driver.
The listener stays open and accepts multiple inbound connections. For
each connection a fresh direct-tcpip channel is opened through the
same SSH session, so drivers that retry or pool (e.g. mysql_async)
work transparently. When the tunnel handle drops, the listener closes
and all active channels are torn down.
Sqlite is rejected
SQLite is local-file only — there’s no host:port for SSH to forward
to. Combining --ssh-tunnel ... sqlite:///path/to/db produces:
SSH tunneling is not applicable to SQLite (local-file backend)
Host-key verification (TOFU)
Ferrule compares the SSH bastion’s server key against your
~/.ssh/known_hosts on every tunnel setup, using russh’s native
OpenSSH-format parser (hashed hosts, [host]:port entries, and all
standard key algorithms are supported).
Three outcomes
| Outcome | In a TTY | In a script / CI |
|---|---|---|
| Host known, key matches | Silent accept | Silent accept |
| Host key mismatch | Fatal error | Fatal error |
| Unknown host | Prompts once for TOFU | Fatal error with instructions |
TOFU prompt (TTY unknown host)
The authenticity of host 'bastion.example.com:22' can't be established.
ED25519 key fingerprint is SHA256:abcdef1234567890abcdef1234567890abcdef12.
Are you sure you want to continue connecting (yes/no)?
Type yes (or y) and ferrule writes the key to
~/.ssh/known_hosts and proceeds. Any other answer aborts with
exit code 2 (USAGE).
Non-interactive unknown host
In CI, pipes, or any non-TTY context, the prompt is skipped:
SSH host bastion.example.com:22 is not in ~/.ssh/known_hosts.
To add it, run interactively once or use:
ssh-keyscan -p 22 bastion.example.com >> ~/.ssh/known_hosts
Pre-seeding known_hosts before the pipeline runs is the standard
OpenSSH-compatible workflow.
Host key mismatch
If the recorded key differs from the one the server presents:
SSH host key mismatch for bastion.example.com:22
The key sent by the server does not match the one recorded in ~/.ssh/known_hosts.
To resolve: verify the new fingerprint and remove the old key:
ssh-keygen -R bastion.example.com -f ~/.ssh/known_hosts
This is always fatal — there is no --insecure-ssh-hosts bypass
flag. Treat a mismatch as a potential man-in-the-middle attack until
you verify the new fingerprint out of band.
SSH and the daemon don’t mix
If you pass --daemon and an SSH tunnel, ferrule rejects the
combination:
SSH tunnels bypass the connection pooling daemon. The tunnel
session lifecycle is tied to the request, so pooling tunneled
connections would introduce a class of failure modes around session
timeout that ferrule does not currently handle. Either drop --daemon
or open without a tunnel.
Why: pooling tunneled connections has a real failure mode that is hard to handle correctly. When the SSH session times out (most bastions kill idle sessions in 5-15 minutes), the pooled DB connection above it goes dead. The DB driver then tries to talk to a now-dead local port and returns confusingly long timeouts. DBeaver has fought this for years; ferrule sidesteps it by not pooling tunnels at all.
If you need a long-lived tunnel for many queries, use the REPL —
ferrule repl --ssh-tunnel ... <conn> keeps a single session open
for the whole REPL session, and \conn <name> switches the inner DB
without reopening the bastion.
Build feature
The SSH stack is opt-in via the ssh Cargo feature:
# Default build — no SSH support, --ssh-tunnel errors out with a
# diagnostic.
cargo build --release
# With SSH support
cargo build --release --features ferrule/ssh
# All features (Oracle + SSH)
cargo build --release --features ferrule/all
The default build excludes russh because the SSH dependency stack adds ~4 MB to the release binary (20 MB → 24 MB on Linux x86_64, measured 2026-04-27). Most users who never tunnel don’t need to pay that.
If you try to use --ssh-tunnel against a default-features
binary:
This ferrule binary was built without the `ssh` feature. Rebuild
with `cargo build --features ferrule/ssh` (or `--features all`).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
connect to <host>:<port>: Connection refused | Bastion isn’t listening on that port, or you’re not on its allow-list | Confirm with plain ssh -p <port> user@host |
publickey auth failed for user 'X' (server rejected key) | Key not in ~user/.ssh/authorized_keys on the bastion | Confirm with plain ssh -i <key> user@host |
SSH agent at <sock> has no identities loaded | Agent is running but empty | ssh-add ~/.ssh/id_ed25519 |
load SSH key from <path>: ... | Wrong passphrase or corrupted key | Check the passphrase or regenerate the key |
SSH key <path> is encrypted. Passphrase prompting requires an interactive terminal. | Encrypted key in a non-interactive context | Use the agent or decrypt on disk (see above) |
SSH host <host>:<port> is not in ~/.ssh/known_hosts. | Unknown bastion in CI / script | Pre-seed with ssh-keyscan (see Host-key verification) |
SSH host key mismatch for <host>:<port> | Server key changed (rebuild / MITM) | Verify fingerprint and ssh-keygen -R <host> |
SSH tunneling is not applicable to SQLite | The URL is a sqlite:// scheme | Drop --ssh-tunnel for sqlite |
| Long hang then “connection failed” | DB host unreachable from the bastion | Confirm with ssh user@host -- nc -zv <db-host> <db-port> |
When in doubt, drop ferrule and try the plain ssh and psql/mysql
binaries against the same hosts. If those work, ferrule should too.
HTTP CONNECT Proxy
Corporate networks often force outbound traffic through an HTTP CONNECT proxy (Squid, Blue Coat, Zscaler, etc.). Ferrule supports this for both direct database connections and SSH tunnels.
Quick start
ferrule query \
--proxy-url http://proxy.corp.example.com:8080 \
"postgres://app:pwd@db.internal:5432/myapp" \
"SELECT * FROM users LIMIT 10;"
This:
- Opens a TCP connection to
proxy.corp.example.com:8080. - Sends
CONNECT db.internal:5432 HTTP/1.1. - After the proxy returns
200 Connection established, negotiates the Postgres protocol through the tunnelled stream.
Proxy configuration layers
Five layers, first hit wins:
--proxy-url <URL>CLI flag.proxy_url = "..."in the profile (.ferrule.toml).FERRULE_<NAME>_PROXY_URL=<URL>env var (where<NAME>is the uppercased connection name with-→_).ALL_PROXY,HTTPS_PROXY, orHTTP_PROXYenv vars.- No proxy.
NO_PROXY is honored at layer 4. If the target host matches a
NO_PROXY entry, the env-var proxy is skipped even when set.
Example profile
[connection.prod-pg]
url = "postgres://app:pwd@db.internal:5432/myapp"
proxy_url = "http://proxy.corp.example.com:8080"
Authenticated proxies
Include credentials in the URL:
ferrule query \
--proxy-url http://user:pass@proxy.corp.example.com:8080 \
...
The credentials are sent via Proxy-Authorization: Basic <base64> during
the CONNECT handshake. Use secrecy::SecretString internally; ferrule
never prints the password in diagnostics.
How each backend connects through the proxy
| Backend | Proxy path |
|---|---|
| Postgres | http_connect → tokio_postgres::Config::connect_raw (direct stream) |
| MySQL | http_connect per accepted connection → local TCP listener → driver |
| MSSQL | http_connect per accepted connection → local TCP listener → driver |
| Oracle | http_connect per accepted connection → local TCP listener → driver |
| SQLite | No-op — SQLite is local-file only |
For MySQL, MSSQL, and Oracle, ferrule binds 127.0.0.1:<random> and
spawns a tiny forwarder: each inbound TCP connection triggers a fresh
http_connect to the database, then tokio::io::copy_bidirectional
pumps bytes. The driver sees only the local port.
Proxy + SSH tunnel (bastion behind a corporate proxy)
Both flags compose: the proxy opens a tunnel to the SSH bastion, then
the SSH tunnel opens a direct-tcpip channel to the database.
ferrule query \
--proxy-url http://proxy.corp.example.com:8080 \
--ssh-tunnel ec2-user@bastion.example.com \
--ssh-key ~/.ssh/id_ed25519 \
"postgres://app:pwd@db.internal:5432/myapp" \
"SELECT * FROM users;"
Byte flow:
ferrule → proxy HTTP CONNECT → bastion:22
→ SSH session
→ direct-tcpip channel → db.internal:5432
→ Postgres protocol
NO_PROXY rules
NO_PROXY supports the same syntax as curl:
*— disables proxy for every host.localhost,127.0.0.1— exact matches, comma-separated..example.com— suffix match (db.example.commatches,example.comdoes not).- Port numbers in patterns are ignored.
Example:
export HTTP_PROXY=http://proxy.corp.example.com:8080
export NO_PROXY="localhost,127.0.0.1,.internal.example.com"
ferrule query "postgres://app:pwd@db.internal.example.com:5432/myapp" "SELECT 1;"
# → NOT proxied (matches .internal.example.com)
ferrule query "postgres://app:pwd@db.external.example.com:5432/myapp" "SELECT 1;"
# → proxied through proxy.corp.example.com:8080
SOCKS5
SOCKS5 is not implemented in this release. If your network requires it,
use a local SOCKS5-to-HTTP-CONNECT adapter (e.g. proxychains-ng or a
small nc wrapper) and point ferrule at that.
Query Telemetry
Ferrule remembers and measures what you do. Every invocation is timed, the redacted connection URL and SQL body are recorded to a local SQLite store, and four flags let you query, benchmark, or gate on that history from the same CLI.
The store is the foundation; the four features layered on top all
hang off the same record_dispatch hook in main.rs.
What gets recorded
Every successful or failing ferrule invocation records exactly one row
into ~/.local/share/ferrule/history.db with these fields:
| Field | Description |
|---|---|
ts | RFC 3339 timestamp (UTC) |
conn | Connection target. For raw URLs this is DatabaseUrl::redacted() — passwords are scrubbed before recording. Registry names and SQLite paths pass through unchanged. |
command | Subcommand name (query, copy, tables, …) |
sql | The SQL body for query-shaped commands. --bench invocations record one rolled-up row with sql = "bench(N): <original SQL>" instead of N per-iteration rows. |
duration_ms | End-to-end wall-clock in milliseconds |
rows | Sample count for --bench, otherwise NULL (the dispatch hook can’t see the per-command row count) |
exit_code | 0 on success, 1 for --fail-on-empty firing or other notable-result gates, 2 for usage errors, 3 for connection failures, 4 for query failures |
error | miette error class on failure: "connection", "query", "usage", "registry", "io", or "result_notable". NULL on success. |
The ferrule history subcommand itself is the only command that does
not record itself — recording every ferrule history --last 5
read would clutter the table without adding signal.
Configuration
[history]
enabled = true # default-on
max_age_days = 30 # 0 disables age-based pruning
max_rows = 100_000 # 0 disables count-based pruning
path = "~/.local/share/ferrule/history.db" # default uses dirs::data_local_dir()
Disable for a single invocation:
FERRULE_NO_HISTORY=1 ferrule query db "SELECT secrets()"
Pruning is open-loop: every recorded run drops rows older than
max_age_days (unless 0) and trims to max_rows (unless 0),
deleting oldest rows first.
Reading history
ferrule history --last 20
ferrule history --conn '*prod*' --slowest
ferrule history --grep "DELETE FROM orders" --since 24h
ferrule history --min-duration-ms 500
Filters AND-combine. --conn is a shell-style glob
(* / ?, case-insensitive). --since accepts 30s, 5m, 2h,
7d. --grep is a case-insensitive substring match on the SQL body.
--slowest sorts by duration_ms descending instead of ts desc.
Output flows through ferrule’s standard --format table|json|csv|yaml|raw
selection.
Slow-query log
Opt-in side channel that tees every run crossing the configured threshold to an append-only file. Useful for “the query that took 30 seconds got lost in scrollback” forensics.
[slow_log]
enabled = true
threshold = "250ms" # humantime (1s, 250ms, 5m, 1h, 2d) or bare integer ms
path = "~/.local/share/ferrule/slow.log"
The log format is tab-separated:
<rfc3339 ts>\t<conn>\t<duration_ms>\t<sql one-line>\t<rows or ->
Read paths:
ferrule history --slow # use the configured threshold
ferrule history --min-duration-ms 1000 # explicit override (overrides --slow)
ferrule slow # alias; --slowest implied, drops --slow flag
Slow-log open failure is fatal at the dispatch boundary because
the user explicitly opted in. The rest of history-store I/O is
swallowed (Ok(_) | Err(_) => return) so the user’s command never
blocks on a busted store.
Benchmark mode
ferrule query db "SELECT count(*) FROM events" --bench 100 --bench-warmup 5
bench: n=100 warmup=5 min=48.8µs mean=69.0µs max=265.8µs
p50=59.0µs p95=103.8µs p99=191.4µs
48.8µs..59.6µs │██████████████████████████████████████████████ 30
59.6µs..70.5µs │█████████████████████ 12
...
The connect cost is taken once outside the loop, so samples represent query time, not handshake + query. Warmup iterations are dropped from the histogram and percentile computation.
--bench-output PATH additionally emits per-iteration timings as
iteration,duration_ns CSV, useful for piping into other tools:
ferrule query db "..." --bench 1000 --bench-output samples.csv
# Compare two queries:
paste -d, q1.csv q2.csv | awk -F, '{print $2-$4}' | sort -n
History interaction: each bench run records exactly one row with
sql="bench(N): <SQL>" and rows=N, not N per-iteration rows.
Gating on empty results
GNU diff convention: exit 0 for “ran cleanly”, 1 for “ran cleanly
but the result is something the caller likely wants to gate on”, 2+
for real errors. ferrule diff already uses code 1. --fail-on-empty
extends that to query and export.
ferrule query prod "SELECT * FROM jobs WHERE failed" --fail-on-empty \
|| ./alert.sh "no failed jobs to retry"
ferrule export prod "SELECT * FROM dlq" --file dlq.csv --fail-on-empty \
|| ./alert.sh "dlq is empty - good news, maybe"
Contract:
- Single SELECT returning 0 rows → exit 1.
- Single SELECT returning ≥1 row → exit 0.
- DML-only batch (
CREATE,INSERT, …) → exit 2 (usage error: the gate is about row count, which DML doesn’t carry). - Multi-statement batch → the first SELECT decides.
The exit-1 case prints a plain stderr line (ferrule: query returned no rows (--fail-on-empty)), not a miette error box. Recorded in
history with exit_code=1, error="result_notable".
What this unblocks
The history store is the foundation for several follow-ups already filed:
- [#5] Result caching by query hash — keys off
(conn_redacted, normalized_sql, params)against a separateresults.db(NOT the history.db; cache eviction churns faster than telemetry retention). Shipped in the Output Polish sprint — see Result Cache. - [#48]
ferrule history prune— explicit pruning beyond the open-loop retention knobs. - [#49] SQL-body redaction — pluggable regex-based scrubbing for the rare case where SQL bodies carry secrets directly instead of via parameter binding.
Quick smoke
# 1. History records a run and we can read it back.
ferrule query "sqlite::memory:" "SELECT 1, 2, 3"
ferrule history --last 1
# 2. Slow log captures the slow run; fast one is skipped.
cat > /tmp/slow.toml <<'EOF'
[slow_log]
enabled = true
threshold = "10ms"
path = "/tmp/ferrule-slow.log"
EOF
ferrule -c /tmp/slow.toml query "sqlite::memory:" \
"WITH RECURSIVE r(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM r WHERE i<200000) SELECT count(*) FROM r"
cat /tmp/ferrule-slow.log # one tab-separated line
# 3. Bench mode produces a histogram and a single history row.
ferrule query "sqlite::memory:" "SELECT 1" --bench 50 --bench-warmup 5
ferrule history --last 1 --format json | grep -q '"bench('
# 4. --fail-on-empty gates exit code.
ferrule query "sqlite::memory:" "SELECT 1 WHERE 0=1" --fail-on-empty; test $? -eq 1
ferrule query "sqlite::memory:" "SELECT 1" --fail-on-empty; test $? -eq 0
Result Cache
Ferrule can transparently cache SELECT results so repeat invocations
return in microseconds instead of round-tripping to the database. The
cache is keyed off (redacted_connection, normalized_sql, named_params), lives in a separate SQLite store from the history
log, and never blocks the user’s query on failure — every cache error
falls through to the real database transparently.
This chapter documents the v1 spike. It is intentionally narrow:
single-statement SELECT only, no DDL invalidation, no cross-machine
sharing, no streaming. See #5b
for the deferred questions.
What’s cached
Exactly one shape: a successful single-statement SELECT. Everything
else bypasses the cache.
| Path | Cached? |
|---|---|
ferrule query db "SELECT ..." | yes |
ferrule query db "INSERT/UPDATE/DELETE/MERGE/CREATE/DROP/ALTER/TRUNCATE..." | no — is_modifying(sql) == true bypasses both lookup and insert |
ferrule query db "SELECT ...; SELECT ..." (multi-statement) | no |
ferrule query db ... --bench N | no — --bench implicitly disables cache |
ferrule query db ... --explain | no |
ferrule query db ... --watch | no |
ferrule query db ... --dry-run | no |
ferrule query db ... --daemon | no |
ferrule query db ... --no-cache | no — explicit bypass for one invocation |
FERRULE_NO_CACHE=1 ferrule query ... | no — env kill switch |
[cache] default_ttl = "0" | no — config disable without disabling lookup |
[cache] enabled = false | no — open-time disable |
Configuration
[cache]
enabled = true # default-on
default_ttl = "5m" # applied to inserts when --cache not passed
max_age_days = 7 # 0 disables age-based pruning
max_rows = 10_000 # 0 disables count-based pruning
path = "~/.local/share/ferrule/results.db" # default uses dirs::data_local_dir()
The store is a separate results.db from the history log
(history.db) — cache eviction churns faster than telemetry retention,
and the two stores are tuned for different access patterns.
Disable for a single invocation:
FERRULE_NO_CACHE=1 ferrule query db "SELECT now()"
# — or —
ferrule query --no-cache db "SELECT now()"
Reading the cache (per-invocation overrides)
# Opt in with an explicit TTL for this query
ferrule query db "SELECT * FROM v_orders" --cache 1h
# Bypass cache for this query but leave the config setting alone
ferrule query db "SELECT * FROM v_orders" --no-cache
# Bypass cache once via env (useful in pipelines / Docker entrypoints)
FERRULE_NO_CACHE=1 ferrule query db "SELECT * FROM v_orders"
--cache <DURATION> overrides [cache] default_ttl for one
invocation. Grammar matches --since: 30s, 5m, 2h, 7d. Pass
--cache 0 to bypass without disabling cache globally.
Under --verbose, ferrule prints a one-line stderr trace on every
cache event:
[ferrule] cache hit (key=ab12cd34…, age=42s)
[ferrule] cache miss; inserted (ttl=300s)
Without --verbose, cache events are silent — the hit looks
identical to a normal query, just faster.
Bypass rules
Cache lookup AND insert are bypassed when ANY of:
--no-cacheset--bench Nset (implicit;--benchmeasures the database, not the cache)--explainset--watchor--watch-fileset (each tick reopens the loop)--dry-runset--daemonset (the daemon path returns a pre-rendered payload)- The SQL is modifying (INSERT/UPDATE/DELETE/MERGE/DDL — anything
ferrule_core::explain::is_modifyingflags) - The SQL is a multi-statement batch (only single-statement SELECT
results are cached; insert is gated on
results.len() == 1) - No
--cacheflag AND[cache] default_ttlis"0"or empty
Bypass is silent unless --verbose.
Failure semantics
The cache is best-effort, never load-bearing. Every failure path falls through to the real query:
| Failure | Behaviour |
|---|---|
| Lookup error (DB locked, etc.) | Run the real query. --verbose prints a stderr trace. |
| Insert error | Print result as normal. --verbose prints a stderr trace. |
Corrupted results.db | Open returns Err; treated as None. Real query runs. |
Schema downgrade (user_version > 1) | CliError::Usage with a hint to delete results.db. |
The cache file is never auto-recovered. Manual reset:
rm ~/.local/share/ferrule/results.db
The next invocation will re-create it with the current schema.
Security
The cache key is derived from DatabaseUrl::redacted() — the
password is scrubbed before it enters Sha256::update. This is
test-asserted (cache_key_never_contains_password_bytes in
ferrule-cli/src/cache.rs).
However, the cached payload itself is user data. rows_json contains
the bytes of every row returned by every SELECT. Treat
results.db like bash_history:
- Inherits home directory permissions (typically
0700) - Don’t sync it across machines unless you trust the destination
- Don’t include it in screen recordings or
tarbundles you share
If you have queries whose results should never be persisted, prefer:
ferrule query db --no-cache "SELECT credit_card_number FROM ..."
What this leaves for follow-up
Filed as [R5b]:
- DDL-elsewhere invalidation. A third client
ALTERs a cached table; ferrule silently returns stale rows until TTL expires. - Eviction on local writes. When ferrule itself runs modifying SQL against a cached connection, should it invalidate matching entries? Today, no.
- Cross-machine shared cache. Redis / S3 backend behind the same
CacheDbtrait. - Multi-statement caching. Round-tripping
Vec<StatementResult>is harder thanQueryResult. - Prepared-statement cache (daemon mode). Shared prepared-stmt + result cache once full daemon mode lands.
- Size budget. Today unbounded until
prune()fires againstmax_rows. Addmax_size_mbanalogous to slow-logmax_size. - Streaming cache. Today the full result set materializes
before insert. Add
max_rows_per_cache_entry(or skip cache above threshold).
Quick smoke
# Isolated config so the smoke doesn't touch ~/.local/share.
cat > /tmp/ferrule-cache.toml <<'EOF'
[cache]
enabled = true
default_ttl = "5m"
path = "/tmp/ferrule-cache.db"
EOF
# 1. Miss + insert.
ferrule -c /tmp/ferrule-cache.toml query \
"sqlite:///tmp/ferrule-cache-data.db" "SELECT 1" --verbose
# stderr: [ferrule] cache miss; inserted (ttl=300s)
# 2. Hit.
ferrule -c /tmp/ferrule-cache.toml query \
"sqlite:///tmp/ferrule-cache-data.db" "SELECT 1" --verbose
# stderr: [ferrule] cache hit (key=..., age=Xs)
# 3. --no-cache bypass.
ferrule -c /tmp/ferrule-cache.toml query \
"sqlite:///tmp/ferrule-cache-data.db" "SELECT 1" --no-cache --verbose
# (no cache-hit/miss line — went straight to the DB)
# 4. is_modifying bypass.
ferrule -c /tmp/ferrule-cache.toml query \
"sqlite:///tmp/ferrule-cache-data.db" \
"CREATE TABLE t(x); INSERT INTO t VALUES (1)" --verbose
# (no cache-hit/miss line — INSERT is modifying SQL)
# 5. Env kill switch.
FERRULE_NO_CACHE=1 ferrule -c /tmp/ferrule-cache.toml query \
"sqlite:///tmp/ferrule-cache-data.db" "SELECT 1" --verbose
# (no cache-hit/miss line — env disabled the cache wholesale)
# Inspect the store directly:
sqlite3 /tmp/ferrule-cache.db \
"SELECT key, ts, ttl_secs, sql_preview FROM cache;"
# Cleanup:
rm /tmp/ferrule-cache.toml /tmp/ferrule-cache.db /tmp/ferrule-cache-data.db
Backends
Ferrule speaks five database protocols. Four are on by default; Oracle
is opt-in. Each is implemented in a feature-gated module under
ferrule-core/src/backends/, and they all expose results through the
unified Value type.
Backend matrix
| Backend | URL schemes | Feature flag | Driver |
|---|---|---|---|
| PostgreSQL | postgres, postgresql | postgres (default) | tokio-postgres + rustls |
| MySQL | mysql, mariadb | mysql (default) | mysql_async |
| MSSQL | mssql, sqlserver, tds | mssql (default) | tiberius |
| SQLite | sqlite | sqlite (default) | rusqlite (bundled) |
| Oracle | oracle | oracle (opt-in) | oracle crate (ODPI-C) |
To opt into Oracle, build with cargo install ferrule --features oracle
and arrange Instant Client at runtime — see
Troubleshooting.
C-free embedding (the ferrule-sql core)
The query core lives in the embeddable ferrule-sql crate
(default = [], every backend opt-in). Embedders under a
no-C-build-dependency policy can depend on ferrule-sql with
Postgres + MySQL and link zero C system libraries:
ferrule-sql = { version = "*", default-features = false, features = ["postgres", "mysql"] }
On that feature set, TLS is rustls pinned to the pure-Rust ring
crypto provider — rustls, tokio-rustls, and mysql_async are all
configured default-features = false so the aws-lc-rs provider (and
its cmake/cc aws-lc-sys build) never enters the graph; mysql_async
uses default-rustls-ring, which also selects flate2’s pure-Rust
miniz_oxide backend in place of libz-sys.
The accepted vendored-static floor is exactly two self-contained
cc-built crates with no system-library linkage: ring (the rustls
crypto floor) and zstd-sys (a hard, non-feature-gated dependency of
mysql_common — its removal is tracked in
issue #94).
The workspace deny.toml bans aws-lc-sys, openssl-sys,
native-tls, and libz-sys, and cargo deny check enforces it on this
surface. The two opt-in backends that fall outside the C-free floor are:
sqlite— bundles SQLite via accbuild (statically linked, no system library), so it is C-free in the no-system-linkage sense but is off the Postgres + MySQL floor above.mssql—tiberius0.12’s stable release only offersnative-tls, which links the platform OpenSSL; themssqlfeature therefore links a C system library and is excluded from thedeny.tomlfirewall graph.oracle— needs the external ODPI-C Instant Client at runtime (see above); it isdlopen’d, not linked at build time.
The ssh tunnel feature is also opt-in; russh is pinned to the ring
provider so it stays C-free, but it carries upstream RustSec advisories
with no in-range fix, so it is excluded from the deny.toml graph (see
the comments in deny.toml for the full rationale).
PostgreSQL
- Pure Rust; no
libpqrequired. - TLS via
rustls(no OpenSSL dependency). - SSL modes:
prefer,require,disable,verify-ca,verify-full. - Multi-statement batches supported —
;-separated statements in one call. - UUID, JSONB, arrays mapped to ferrule
Valuetypes natively. - Numeric / decimal preserved as
Value::Decimal(string-backed) to avoid precision loss.
ferrule query "postgres://user:pass@host/db?sslmode=require" "SELECT 1;"
For TLS posture, verify-full checks the full chain and the
hostname; require encrypts but doesn’t verify identity. See
Security for guidance on
which to use when.
MySQL
- Pure Rust via
mysql_async. - Works with MySQL 5.7+ and MariaDB 10.3+.
- TLS handled by the driver; opt in via the URL or server config.
- JSON column type maps to
Value::Json. - ENUM columns map to
Value::String(the variant name). - Multi-statement batches supported —
;-separated statements in one call viamysql_asyncnative multi-result API.
ferrule query "mysql://root:pass@127.0.0.1:3306/mydb" "SELECT 1; SELECT 2;"
If you hit caching_sha2_password errors, see
Troubleshooting.
MSSQL
- Pure Rust via
tiberius(TDS protocol — Microsoft’s wire format). - Supports SQL Authentication out of the box; Windows Authentication
/ Kerberos depends on platform support in
tiberius. - Multi-statement batches supported.
DATETIMEOFFSET→Value::DateTimeTz;BIT→Value::Bool;UNIQUEIDENTIFIER→Value::Uuid.- No native JSON type — store JSON in
NVARCHAR(MAX)and ferrule returns it as eitherValue::Json(if it parses) orValue::String.
ferrule query "mssql://sa:pass@host/db?trustServerCertificate=true" "SELECT 1;"
The trustServerCertificate=true query parameter accepts a
self-signed cert only — that’s narrower than the global
--insecure flag and the right choice for Docker test images that
ship a self-signed cert.
SQLite
- Statically linked via
rusqlitewith thebundledfeature; no runtime library required. - File-based, in-memory (
sqlite::memory:), or shared-cache (sqlite::memory:?cache=shared) variants supported. - Single-statement only at the ferrule layer; multi-statement
scripts run via SQLite’s own
execfrom the driver, but ferrule doesn’t surface multi-statement batches. - Schema introspection uses
pragma_table_infoandsqlite_schema— see Schema Introspection.
ferrule query "sqlite::memory:" "SELECT 1;"
ferrule query "sqlite:///tmp/mydb.sqlite3" "SELECT * FROM users;"
The bundled SQLite version is whatever rusqlite ships with at
build time — usually a few minor versions behind the latest.
Oracle
Opt-in only. Compiled in with cargo build --features oracle. At
runtime, the oracle crate dynamically loads libclntsh.so (Linux
/ macOS) / oci.dll (Windows) from Oracle Instant Client. Without
Instant Client present, the first connection fails with a ferrule
diagnostic:
ferrule::connection
× Oracle Instant Client (libclntsh.so) not found.
cargo install ferrule --features oracle
export LD_LIBRARY_PATH="$HOME/opt/oracle/instantclient_23_26:$LD_LIBRARY_PATH"
ferrule query "oracle://user:pass@host:1521/service" "SELECT * FROM dual;"
Schema notes:
- No native
BOOLEANuntil 23c — most schemas useNUMBER(1). - No native
UUID— useRAW(16) DEFAULT SYS_GUID(). - JSON is
CLOBwith anIS JSONcheck constraint (12c+).
Setup details, including the libaio symlink workaround for Ubuntu
24.04+, live in Troubleshooting.
TLS posture summary
| Backend | TLS by default? | Requires it? | Self-signed dev cert |
|---|---|---|---|
| PostgreSQL | Negotiated; client decides | ?sslmode=require (encrypt) / verify-full (full verify) | ?sslmode=require + --insecure |
| MySQL | Off unless server demands; rustls | server-side require_secure_transport | --insecure |
| MSSQL | Always negotiated; cert often self-signed | (default) | ?trustServerCertificate=true or --insecure |
| SQLite | N/A — local file or memory | — | — |
| Oracle | Server-configured (TNS) | TNS listener config | TNS-side, not URL |
--insecure disables both certificate-chain verification and
hostname verification globally for the call. The narrower MSSQL
query parameter is preferred where it applies.
Multi-statement support
| Backend | Batch via ; | Notes |
|---|---|---|
| PostgreSQL | ✅ | First-class; result sets and DML row counts both reported |
| MySQL | ✅ | Via mysql_async multi-result API |
| MSSQL | ✅ | Via TDS row-set framing |
| SQLite | ❌ | Use a script via --file if needed; one statement per query call |
| Oracle | ✅ | Semicolon-split with PL/SQL block awareness; BEGIN … END, IF … END IF, LOOP … END LOOP, and CASE … END CASE are kept intact |
When using batches, remember --limit and --offset are not
allowed — see Querying for the workaround.
Type mapping
Ferrule maps every backend’s native types to a single Value enum.
This table is the canonical home — it’s referenced from
Concepts, Querying, and the rest of
the docs.
Ferrule Value | Postgres | MySQL | MSSQL | SQLite | Oracle |
|---|---|---|---|---|---|
Bool | BOOLEAN | BOOLEAN (TINYINT(1)) | BIT | INTEGER | NUMBER(1) |
Int64 | BIGINT | BIGINT | BIGINT | INTEGER | NUMBER |
Float64 | DOUBLE PRECISION | DOUBLE | FLOAT | REAL | BINARY_FLOAT |
Decimal | NUMERIC | DECIMAL | DECIMAL | NUMERIC | NUMBER |
String | TEXT / VARCHAR | VARCHAR | NVARCHAR | TEXT | VARCHAR2 |
Bytes | BYTEA | BLOB | VARBINARY | BLOB | RAW |
Date | DATE | DATE | DATE | TEXT (ISO 8601) | DATE |
DateTime | TIMESTAMP | DATETIME | DATETIME2 | TEXT (ISO 8601) | TIMESTAMP |
DateTimeTz | TIMESTAMPTZ | TIMESTAMP | DATETIMEOFFSET | TEXT (ISO 8601) | TIMESTAMP WITH TIME ZONE |
Json | JSONB / JSON | JSON | NVARCHAR(MAX) (JSON-shaped) | TEXT | CLOB (with IS JSON) |
Uuid | UUID | CHAR(36) | UNIQUEIDENTIFIER | TEXT | RAW(16) |
Array | T[] (any) | (n/a — pass JSON) | (n/a — pass JSON) | (n/a) | (n/a — pass JSON) |
Types that don’t have a clean Value slot (Postgres ranges, custom
composite types, MySQL SET, etc.) fall back to Value::String
with the driver’s native rendering.
Troubleshooting
When something goes wrong, the fastest path is usually:
- Re-run with
-v/--verboseto see the resolved URL and SQL. - Add
--timingto see whether you’re stuck on connect, query, or format. - Match the symptom to one of the entries below.
Exit codes (echo $?) tell you which class of result you hit:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Notable result — ferrule diff found differences, future --expect-rows-style assertions. GNU diff / grep convention. Not an error |
| 2 | Usage / argument error (matches clap’s parse-error exit) |
| 3 | Connection error (TLS, auth, network) |
| 4 | Query error (SQL syntax, constraint, schema) |
Connection errors (exit code 3)
connection refused / host unreachable
The TCP connection didn’t open. Check:
- Container or DB process is actually running. For the docker setups
in
CLAUDE.md:docker ps | grep ferrule-. - Port is published to localhost. The test setups bind to
127.0.0.1:15432/:13306/:11433/:11521— not the default ports. - Firewall isn’t dropping it. Try
nc -zv host portfirst; ifnccan’t connect, ferrule won’t either.
TLS handshake failed / unknown issuer
The server presented a certificate ferrule’s trust store doesn’t accept.
- For PostgreSQL with a public cert, prefer
?sslmode=verify-full(or omitsslmodeto negotiate) —verify-fullchecks the chain and the hostname. - For PostgreSQL with a private CA, install the CA into the system
trust store (rustls reads
/etc/ssl/certson Linux). There is no per-connection CA flag yet. - For MSSQL with a self-signed cert (the docker test image),
?trustServerCertificate=trueaccepts it.--insecureis the blanket equivalent. - Don’t reach for
--insecureuntil you’ve checked the cert is what you expect:openssl s_client -connect host:5432 -starttls postgres </dev/null 2>/dev/null | openssl x509 -text.
password authentication failed / Login failed
The driver got past TLS and onto auth, then was rejected.
- Re-run with
-vto see the resolved URL — confirm the user is what you expected after profile substitution. - Try the password manually with the native client (
psql,mysql,sqlcmd). If the native client also fails, the password is wrong; if it succeeds, ferrule resolved a different password than you thought. - Check the credential stack order in Concepts
— a stale
FERRULE_<NAME>_PASSWORDenv var beats a freshly stored keyring entry.
Could not resolve password for '<name>'
Ferrule walked the entire stack and got nothing. The diagnostic lists each step and why it failed:
ferrule::connection
× Could not resolve password for 'production'
├─ No --password flag
├─ password_url not configured
├─ FERRULE_PRODUCTION_PASSWORD is unset
├─ keyring://ferrule/production: not found
└─ Terminal is not interactive
Common fixes:
- Add a
password_urlin.ferrule.toml, or ferrule conn set-password <name>to store one in the keyring, or- Run from an interactive terminal (not
nohup, not cron).
Keyring permission denied
The keyring is locked, or there’s no Secret Service to talk to.
- Linux over SSH:
ssh -Yforwards the X session and unlocks GNOME Keyring; without it, the keyring is locked. Alternatively, unlock manually:gnome-keyring-daemon --unlock < pwfile. - Linux in cron / systemd: D-Bus user session usually isn’t
running. Switch to
file://for headless contexts. - macOS: Run
security unlock-keychainonce per session if you’re invoking from a non-GUI shell.
Query errors (exit code 4)
Multi-statement SQL does not support --limit / --offset
You sent multiple ;-separated statements, and --limit (or the
default limit from [default]) is set. Either:
- Pass
--limit 0to disable paging for this call, or - Split the statements into separate
ferrule querycalls, or - Set
[default] limit = 0in.ferrule.tomlif you mostly run multi-statement DDL.
The default limit is 1000, applied even when --limit is absent —
that’s why this error fires for batches you don’t think have a limit.
relation "..." does not exist / Invalid object name
Schema, search-path, or case issues.
- Postgres: re-run with explicit schema (
SELECT * FROM public.users) or set the search path:ferrule query prod "SET search_path = my_schema; SELECT * FROM users". - MSSQL: schema-qualified names are
[schema].[table]ordbo.users. - SQLite: tables are case-sensitive but unquoted identifiers are
case-folded —
SELECT * FROM Usersandusersmay target the same table or not, depending on how it was created.
JSON parse error when piping to jq
The default output format is JSON — but --timing, --verbose, and
warnings print to stderr, not stdout. If you see them in your jq
input, you’re capturing both streams. Use 2>/dev/null or 2>&1 1>&3
patterns to keep them apart.
Backend-specific gotchas
PostgreSQL: sslmode=require vs verify-full
sslmode=require encrypts the connection but doesn’t verify who
you’re talking to — a MITM with any valid cert can intercept. Use
verify-full for production (default rustls trust store) and keep
require for development against self-signed certs paired with
--insecure.
MySQL: caching_sha2_password errors with old clients
MySQL 8 defaults to caching_sha2_password. mysql_async supports
it, but the first connection after a server restart sometimes fails
with auth method unknown. Reconnecting succeeds. If you can’t
upgrade, change the user’s auth plugin server-side:
ALTER USER 'app'@'%' IDENTIFIED WITH mysql_native_password BY '...';
MSSQL: self-signed cert in the docker test image
The official mcr.microsoft.com/mssql/server image ships a
self-signed TLS cert. Append ?trustServerCertificate=true to the
URL — that’s narrower than --insecure, since it accepts the
self-signed cert without disabling hostname verification globally.
Oracle: libclntsh.so not found
The oracle feature is opt-in. The crate dynamically links against
Oracle Instant Client at runtime; ferrule’s compile step does not
require it.
ferrule::connection
× Oracle Instant Client (libclntsh.so) not found.
└─ Install Instant Client and add to LD_LIBRARY_PATH:
https://www.oracle.com/database/technologies/instant-client.html
Steps (Linux x86_64):
- Download Basic from Oracle’s site (license click-through required).
unzip -q instantclient-basic-linux.x64-*.zip -d ~/opt/oracleexport LD_LIBRARY_PATH="$HOME/opt/oracle/instantclient_23_26:$LD_LIBRARY_PATH"- On Ubuntu 24.04+, symlink
libaio:ln -sf /usr/lib/x86_64-linux-gnu/libaio.so.1t64 \ ~/opt/oracle/instantclient_23_26/libaio.so.1 - Verify:
ldd ~/opt/oracle/instantclient_*/libclntsh.so | grep "not found"should print nothing.
Don’t extract libclntsh.so from a running database container — it
expects a full $ORACLE_HOME layout and segfaults at init.
Performance
--timing for a quick breakdown
ferrule query prod "SELECT * FROM big_table" --timing
Prints to stderr:
[ferrule] connect: 142ms
[ferrule] query: 3.41s
[ferrule] format: 18ms
[ferrule] total: 3.57s
If connect dominates and you’re running many short queries, the
connection-pooling daemon is what you want.
--limit is client-side after the query runs
--limit slices results after they reach ferrule. For paging on
the server side, write LIMIT / OFFSET (or OFFSET ... FETCH NEXT for MSSQL) into the SQL itself. The auto-injection that fires
when neither limit nor --limit 0 is present handles single-statement
queries; for fine-grained control, do it explicitly.
REPL
Interactive REPL requires a TTY
The REPL refuses to start if stdin isn’t a terminal — running it
under nohup or piping into it will trip this. For headless
batch use, fall back to ferrule query --stdin:
echo "SELECT 1;" | ferrule query prod --stdin
History keeps growing
REPL history lives at ~/.cache/ferrule/history and grows unbounded.
If it gets unwieldy, truncate it:
tail -n 1000 ~/.cache/ferrule/history > ~/.cache/ferrule/history.new
mv ~/.cache/ferrule/history.new ~/.cache/ferrule/history
Still stuck
Open an issue with:
- The output of
ferrule --version. - The full command you ran (passwords redacted).
- The full stderr (with
-v --timing). - The host OS and the backend version.
- Whether the native client (
psql/mysql/sqlcmd) reproduces the failure with the same URL.
Reports that include all five resolve in roughly half the time of ones that don’t.
CLI Reference
Quick lookup for commands, flags, exit codes, and file locations. For conceptual context, start with Concepts.
Commands
ferrule [--config <path>] <subcommand> [args...]
Aliases: ferrule conn for ferrule connection, ferrule q for
ferrule query, ferrule r for ferrule repl.
query — execute SQL
ferrule query <connection> [<sql>]
[--file <path>] [--stdin]
[--param NAME=VALUE]... [--param-file <json>]
[--explain] [--dry-run]
[--filter <expr>]
[--watch] [--watch-interval <secs>]
[--format <fmt>] [--output <file>]
[--limit <n>] [--offset <n>]
[--timing] [--verbose]
[--insecure] [--daemon]
[--password <pwd>]
See Querying Data.
tables / describe / diff — schema introspection
ferrule tables <connection> [output flags] [connection flags]
ferrule describe <connection> <table> [output flags] [connection flags]
ferrule diff <connection-a> <connection-b> [diff flags] [output flags] [connection flags]
See Schema Introspection and Schema Diff.
explain — execution plans
ferrule explain <connection> <sql>
[--analyze]
[--format <fmt>] [--output <file>]
[--timing] [--verbose]
[--insecure] [--daemon]
dump / load — data movement
ferrule dump <connection> <table>
[--file <path>]
[--dump-format csv|json|sql]
[--schema <name>]
[--limit <n>] [--offset <n>]
[--timing] [--verbose]
[--insecure] [--daemon]
ferrule load <connection> <file>
[--table <name>]
[--format csv|json]
[--create-table]
[--insecure] [--daemon]
See Dump and Load.
watch — periodic re-execution
ferrule watch <connection> <sql>
[--interval <seconds>]
[--max-iterations <n>]
[--diff]
[--exit-on-error]
[--bell]
[--file-path <path>]
[--format <fmt>] [--output <file>]
[--timing] [--verbose]
[--insecure] [--daemon]
[--password <pwd>]
See Watch mode.
repl — interactive shell
ferrule repl [<connection>]
[--format <fmt>] [--output <file>]
[--limit <n>] [--offset <n>]
[--timing] [--verbose]
[--insecure] [--daemon]
See Interactive REPL.
bookmark — saved queries
ferrule bookmark add <name> <sql> [--connection <name>]
ferrule bookmark list
ferrule bookmark run <name> [<arg>...]
[--connection <name>]
[--edit]
[--format <fmt>] [--output <file>]
[--limit <n>] [--offset <n>]
[--timing] [--verbose]
[--insecure] [--daemon]
ferrule bookmark delete <name>
See Bookmarks.
export — streaming SQL-to-file export
ferrule export <connection> <sql>
[--file <path>]
[--format csv|json|jsonl|sql]
[--page-size <n>]
[--timing] [--verbose]
[--insecure] [--daemon]
See Export.
diff — schema comparison between two connections
ferrule diff <connection-a> <connection-b>
[--table <name>]
[--password-a <pwd>] [--password-b <pwd>]
[--format <fmt>] [--output <file>]
[--timing] [--verbose]
[--insecure] [--daemon]
See Schema Diff.
connection (alias conn) — registry and daemon
ferrule conn add <name> <url>
ferrule conn list
ferrule conn remove <name>
ferrule conn test <name> [--insecure] [--daemon]
ferrule conn set-password <name> # interactive prompt; stores in OS keyring
ferrule conn delete-password <name>
ferrule conn start [--background] # connection-pooling daemon
ferrule conn stop
ferrule conn status
ferrule conn restart
See Connections and Daemon.
Flags reference
Output flags
Available on query, tables, describe, explain, dump,
watch, repl, bookmark run.
| Flag | Description |
|---|---|
-f, --format <fmt> | One of table, json, csv, yaml, raw. Default: json |
-o, --output <file> | Write results to a file (otherwise stdout) |
-n, --limit <n> | Cap the number of rows returned. 0 disables paging for the call |
--offset <n> | Skip the first <n> rows |
--timing | Print connect/query/format timing to stderr |
-v, --verbose | Echo resolved (redacted) URL and SQL to stderr |
Connection flags
Available on every command that opens a connection.
| Flag | Description |
|---|---|
--insecure | Disable TLS certificate-chain and hostname verification. Prints a warning to stderr. See Security |
--daemon | Route the request through the per-user connection-pooling daemon (must be running — ferrule conn start). See Daemon |
Password flag (query and watch only)
| Flag | Description |
|---|---|
-p, --password <pwd> | Pass the password explicitly. Insecure — leaks to shell history. Prefer password_url in Configuration |
Query-only flags
| Flag | Description |
|---|---|
--file <path> | Read SQL from a file |
--stdin | Read SQL from stdin |
--param NAME=VALUE | Set a ${NAME} placeholder. Repeat for multiple parameters |
--param-file <path> | Load parameters from a JSON object file |
--explain | Wrap the SQL in EXPLAIN before sending |
--dry-run | Print the resolved SQL and URL; do not connect |
--filter <expr> | JMESPath expression to filter/reshape JSON output before printing. Implies --format json |
--watch | Re-run the query periodically (delegates to watch command) |
--watch-interval <secs> | Polling interval when --watch is set. Default: 5 |
Explain-only flags
| Flag | Description |
|---|---|
--analyze | Execute the statement and collect runtime statistics. Silently downgraded for INSERT / UPDATE / DELETE / DDL to avoid side effects |
Dump-only flags
| Flag | Description |
|---|---|
--file <path> | Output file (stdout if omitted) |
--dump-format <fmt> | One of csv, json, sql. Default: csv. Distinct from --format |
--schema <name> | Schema name; affects qualified table names in SQL dumps |
Load-only flags
| Flag | Description |
|---|---|
-t, --table <name> | Target table (inferred from file stem if omitted) |
-f, --format <fmt> | csv or json (inferred from extension if omitted) |
--create-table | Create the target table from the JSON schema before loading. JSON only |
Watch-only flags
| Flag | Description |
|---|---|
-i, --interval <seconds> | Polling interval. Default: 5. Minimum: 1 |
--max-iterations <n> | Stop after <n> iterations |
--diff | Only print output when the result differs from the previous iteration |
--file-path <path> | Watch the given SQL file and re-run when it changes |
--exit-on-error | Terminate on the first connection or query failure |
--bell | Ring the terminal bell (ASCII BEL) when --diff detects a change |
Export-only flags
| Flag | Description |
|---|---|
--file <path> | Output file (stdout if omitted) |
--format <fmt> | One of csv, json, jsonl, sql. Default: csv |
--page-size <n> | Server-side page size. Default: 1000 |
--schema <name> | Schema name; affects qualified table names in SQL dumps |
--table <name> | Target table name for SQL-format output. Default: exported |
Diff-only flags
| Flag | Description |
|---|---|
--password-a <pwd> | Override credential stack for connection A (left side) |
--password-b <pwd> | Override credential stack for connection B (right side) |
Daemon-only flags
| Flag | Description |
|---|---|
--background (on conn start) | Fork to background and detach |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Notable result — ferrule diff found schema differences. Reserved for future --expect-rows-style assertions and any future check / validate / lint commands. Follows the GNU diff / grep / kubectl diff convention: the command ran correctly and produced a result you likely want to gate on. Not an error. |
| 2 | Usage error (invalid CLI arguments, malformed SQL parsing). Matches clap’s default exit code for argument-parse failures |
| 3 | Connection error (network, TLS, authentication, backend unavailable) |
| 4 | Query error (SQL syntax, constraint violation, schema mismatch) |
Environment variables
| Variable | Effect |
|---|---|
FERRULE_<NAME>_PASSWORD | Legacy password fallback for connection <name> (uppercased; hyphens become underscores). production → FERRULE_PRODUCTION_PASSWORD |
RUST_LOG | Enable structured logging from ferrule and driver crates. RUST_LOG=ferrule=debug is a useful starting point |
FERRULE_CONFIG is not currently a recognized override; pass
--config on the command line.
File locations
| File | Purpose | Path |
|---|---|---|
| Global config | Per-user defaults, connection profiles | ~/.config/ferrule/ferrule.toml |
| Project config | Project-local overrides | ./.ferrule.toml |
| Connections registry | Saved name → URL (managed by ferrule conn add) | ~/.config/ferrule/connections.toml |
| Bookmarks | Saved query library | ~/.config/ferrule/bookmarks.toml |
| REPL history | Persistent history file | ~/.cache/ferrule/history |
| Daemon socket (Unix) | Per-user IPC for --daemon mode | ~/.cache/ferrule/daemon.sock |
| Daemon PID | PID file for the running daemon | ~/.cache/ferrule/daemon.pid |
| Daemon port (Windows) | TCP port file (Unix uses sockets) | %LOCALAPPDATA%\ferrule\daemon.port |
Paths use dirs::config_dir() and dirs::cache_dir() — replace
~/.config and ~/.cache with the platform equivalent on macOS
(~/Library/Application Support/, ~/Library/Caches/) and Windows
(%APPDATA%, %LOCALAPPDATA%).
Type mapping
The canonical type-mapping table lives in Backends.
Credential resolution
The canonical credential resolution stack lives in Concepts; the security trade-offs of each scheme live in Security.