Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, no libmysqlclient, no unixodbc. The binary drops onto a fresh box and works.
  • A unified type system. Every backend maps native types into a single Value enum, 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 table for human-readable terminal use, plus CSV / YAML / raw for pipelines.
  • Same command surface across backends. ferrule query, ferrule tables, ferrule describe, ferrule explain all 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 like mysqldump, 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

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

FeatureDefaultNotes
postgrestokio-postgres + rustls (no OpenSSL)
mysqlmysql_async
mssqltiberius
sqliterusqlite with bundled SQLite
oracleoracle 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

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:

  1. Raw URL. If <CONNECTION> parses as a database URL (postgres://..., mysql://..., sqlite::memory:, etc.), ferrule uses it directly.
  2. 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 a password_url and other defaults.
  3. 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 by ferrule 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 prod and a [connection.prod] block in .ferrule.toml, the profile wins. This lets a project-local .ferrule.toml shadow 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:

  1. --password CLI flag. Explicit, ephemeral, and intentionally loud. Useful for one-off debugging; bad for daily use.
  2. password_url from the active profile. Whatever the password_url in .ferrule.toml resolves to, via the hasp URL scheme — env://, keyring://, or file://.
  3. FERRULE_<NAME>_PASSWORD env var. Legacy fallback. productionFERRULE_PRODUCTION_PASSWORD. Hyphens become underscores: local-dbFERRULE_LOCAL_DB_PASSWORD.
  4. OS keyring at keyring://ferrule/<name>. Whatever ferrule conn set-password <name> stored. macOS Keychain, Windows Credential Manager, or libsecret on Linux.
  5. Interactive prompt. Only fires if stdin is a TTY.
  6. 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:

  1. --format <fmt> on the command line.
  2. default.format in the loaded .ferrule.toml.
  3. 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:

  • --limit and --offset are not allowed with multi-statement SQL. The default limit (1000 from [default]) counts. Set --limit 0 to 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 query calls 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

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

BackendURL PatternNotes
PostgreSQLpostgres://user:pass@host:port/db?sslmode=disablesslmode values: prefer, require, disable
MySQLmysql://user:pass@host:port/db
MSSQLmssql://user:pass@host:port/db?trustServerCertificate=trueSet trustServerCertificate=true for self-signed certs
SQLitesqlite:///absolute/path or sqlite::memory::memory: does not work after ://; use the form above
Oracleoracle://user:pass@host:port/service_nameRequires --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.

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.

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 variable
  • keyring://service/account — OS keyring
  • file:///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:

  • productionFERRULE_PRODUCTION_PASSWORD
  • local-dbFERRULE_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:

  1. --config <path> on the command line.
  2. ./.ferrule.toml in the current working directory (project-local).
  3. ~/.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 by ferrule 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

FieldTypeDefaultNotes
formatstring"json"One of table / json / csv / yaml / raw. Overridden per-call by --format.
limitinteger1000Default LIMIT for single-statement queries. Set to 0 to disable. Multi-statement batches reject any non-zero value.
timeoutinteger30Connection 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.

FieldTypeRequiredNotes
urlstringyesDatabase URL. May contain ${ENV_VAR} and ${ENV_VAR:-default} interpolation.
password_urlstringnoHasp URL for credential resolution. See below.
headerstablenoReserved; 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:

SchemeExampleNotes
env://env://STAGING_DB_PASSWORDReads an env var
keyring://keyring://ferrule/productionOS keyring (service / account)
file://file:///run/secrets/db_passwordFile 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, not table. To get pretty tables interactively, set format = "table" here or pass --format table per call.
  • The default limit is 1000, applied to every single-statement query that doesn’t override it. Set limit = 0 if you don’t want this.
  • Profiles take precedence over the registry. If a project-local .ferrule.toml and ~/.config/ferrule/connections.toml both define prod, the profile wins.
  • Typos are now hard errors. Every [default] and [connection] key is validated; a misspelled field like ssh_hostt produces 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

VariableEffect
FERRULE_<NAME>_PASSWORDLegacy password fallback for connection <name>
RUST_LOGEnable 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.

BackendTLS by defaultHow to require itSelf-signed dev cert
PostgreSQLNegotiated; client decides?sslmode=require (encryption only) or ?sslmode=verify-full (chain + hostname)?sslmode=require + --insecure
MySQLOff unless server requires; rustls-basedServer-side require_secure_transport--insecure
MSSQLAlways negotiated; cert often self-signed(default)?trustServerCertificate=true or --insecure
SQLiteN/A — local file or memory
OracleServer-configuredTNS listener configTNS-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.md is 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:

SchemeWhat it isVisible toSurvives reboot
env://NAMEEnvironment variable in this processAnything reading /proc/<pid>/environ for this PID + every childNo (set per-shell)
keyring://service/accountOS keyring entryCode running as the same desktop sessionYes
file:///pathFile on diskAnyone who can read(2) the fileYes

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::SecretString in memory. Debug formatting prints [REDACTED]; the underlying buffer is zeroed on drop.
  • URLs are redacted before logging. The --verbose flag prints the resolved URL with the password component replaced by ***:
    [ferrule] Resolved URL: postgres://user:***@host/db
    
    Search the codebase for display_redacted if you want to see exactly what is and isn’t elided.
  • Error messages from drivers may not redact. A driver-level error from tokio-postgres or tiberius is 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 add writes to plain-text TOML. Use it only for URLs without passwords. Add passwords with set-password afterward.
  • 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

FormatFlagBest for
Table--format tableHuman-readable terminal output
JSON--format jsonPiping to jq, APIs, structured tooling
CSV--format csvSpreadsheets, data pipelines
YAML--format yamlHuman-readable structured data, config-y outputs
Raw--format rawTab-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 htmlStatic <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:

GuardDefaultWhat it caps
max_cell_bytes64 MiBa single value (e.g. a giant bytea / TEXT)
max_row_bytes256 MiBone row’s summed cell payloads
max_total_buffered_bytes1 GiBthe 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:

  1. 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 via ferrule_sql::SizeGuards).
  2. 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_cursor streaming API, which pulls from a native database cursor at O(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-oriented csv / jsonl formats 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] is limit = 1000. If you don’t pass --limit and don’t override the default, ferrule still appends LIMIT 1000. To disable globally, set limit = 0 in [default].
  • --limit 0 disables 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 N separator.
  • 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 query calls 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 transaction on stderr, and surfaces the original SQL error as exit code 4.
  • --rollback on success: ferrule still rolls back and prints [ferrule] explicit ROLLBACK (--rollback) on stderr; exit code 0 when SQL + ROLLBACK both succeed.
  • --commit requires --begin (clap exit 2). --rollback requires --begin. --commit and --rollback conflict (clap exit 2).
  • --begin --daemon is rejected: the daemon path doesn’t guarantee per-tick connection affinity, which would silently dissolve the transaction across pool checkouts.
  • --begin --watch is rejected: each watch tick would reopen a separate transaction.
  • --begin --bench N wraps the whole loop in ONE outer transaction (not N separate ones). Pairs with --bench --rollback for side-effect-free microbenchmarks.
  • Backend SQL emitted: BEGIN / COMMIT / ROLLBACK for PostgreSQL, MySQL, SQLite. BEGIN TRANSACTION / COMMIT TRANSACTION / ROLLBACK TRANSACTION for 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 + describe their 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 inline ENUM).
  • Composite types and ranges (Postgres-specific).
  • Generated / computed column expressions beyond the bare column_default field.
  • Triggers, stored procedures, sequences. Query the relevant catalog (pg_catalog.pg_proc, INFORMATION_SCHEMA.ROUTINES, etc.) with ferrule query for 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 that ferrule query --param uses. Named params are CLI-only because they require explicit name=value mapping at call time.

--edit opens 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:

CommandDescription
\qQuit 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 clearClear all session parameters
\param listList currently-set parameters
\bookmark save <name>Save the previous SQL as a bookmark
\bookmark listList saved bookmarks
\bookmark run <name> [args…]Run a bookmark with positional params
\bookmark delete <name>Delete a bookmark
\gRe-run the previous SQL statement
\explainToggle EXPLAIN-mode (wraps every executed query)
\explain on|off|toggleSet EXPLAIN-mode explicitly
\explain <sql>Explain a single query (one-shot)
\watchRe-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
\helpShow 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 FILE to 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 / \dt exploration.

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 onFormat
PostgreSQLEXPLAIN (FORMAT JSON, COSTS) …EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS, TIMING, COSTS) …JSON
MySQLEXPLAIN FORMAT=JSON …(same — MySQL EXPLAIN doesn’t execute)JSON
SQLiteEXPLAIN QUERY PLAN …(same)Text
MSSQLSET SHOWPLAN_XML ON; …SET STATISTICS XML ON; …XML
OracleEXPLAIN 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 Value types).
  • 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-format vs --format. They’re separate. --format controls the display format ferrule uses for stderr / output; --dump-format controls 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 to ORDER BY every 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 MySQL JSON (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 → Int64 or Float64 depending 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 a Bool column 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

FormatDescription
csvComma-separated values; newlines inside strings are escaped
jsonOne JSON array of objects
jsonlOne JSON object per line
sqlINSERT INTO ... VALUES (...) statements

Notes

  • --file is optional; without it, the result goes to stdout.
  • --page-size defaults to 1000 and controls the server-side chunk size. Use --page-size 0 to disable paging (not recommended for huge result sets).
  • --limit and --offset are respected just like query.

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 \!\! in psql over and over.
  • Re-running a query as you edit a .sql file 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 raw or --format json for 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:

OSEndpointDiscovery 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)
Windows127.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

CombinationResult
--filter + single SELECT resultFilter runs on the result rows array
--filter + INSERT/UPDATE/DELETE (summary)Rejected — “requires a SELECT-style query that returns rows”
--filter + multi-statementRejected — “cannot be applied to multi-statement queries”
--filter + --explainRejected — explain payloads are XML/text/JSON-of-plan, not row data
Invalid JMESPath syntaxExits 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.

PatternMeaning
[*].fieldProject 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_type differs across the two (~ lines).

For tables that exist on only one side:

  • Listed under Tables only in A or Tables 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) and character varying(255) will show as different, even though they are the same type — the underlying data_type column 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

CapabilityStatusNotes
Per-backend type translationShippedSee “Type translation” below
Generic multi-row INSERTShippedDefault; portable across all five backends
Native bulk pathsShipped--bulk-native auto|on; PG / MSSQL / MySQL / Oracle
error / append / truncateShipped--if-exists; truncate requires --yes from a TTY
skip / upsertShipped--if-exists; PK-driven, force generic path
Schema-level copy (FK-ordered)Shipped--all-tables with --include / --exclude / --no-fk-check
Postgres binary COPYShipped--copy-format binary; PG-only
Composite-key / unique-index overrideShipped--key COL[,COL...]; closes #43
Preserve source PK in --create-tableShipped--preserve-pk; closes #45
Per-side --src-* / --dst-* flagsShippedSSH / proxy / key / insecure; closes #44
Daemon routing for copyDeferredTracked under #10
Parallel loader (--parallel N)DeferredDepends on the above; tracked under #38
Oracle direct-path INSERT /*+ APPEND */DeferredTracked under #37
--bulk-native default flip to autoDeferredTracked 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:

  1. SELECT one page from the source, paged via LIMIT/OFFSET or the dialect equivalent (same machinery as ferrule dump).
  2. Translate the source column types into a target-side DDL when --create-table is set (see “Type translation” below).
  3. 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 stateDefault behavior
Doesn’t exist + --create-tableCreate + insert
Doesn’t exist, no --create-tableUsage error (exit 2)
Exists, emptyInsert (treated as fresh)
Exists, non-emptyError (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.
  • truncateDELETE FROM <tbl> then INSERT. Destructive, requires --yes when 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
  • 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 MERGE with both WHEN MATCHED THEN UPDATE and WHEN NOT MATCHED THEN INSERT branches.

skip and upsert need conflict columns. Three ways to supply them, checked in this order:

  1. --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. --key is ignored (with a one-line stderr notice) for non-conflict strategies.
  2. Destination’s declared primary key — auto-detected via Connection::primary_key when --key is absent.
  3. 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:

TypeHintPostgresMySQLMSSQLSQLiteOracle
BoolBOOLEANTINYINT(1)BITINTEGERNUMBER(1)
Int64BIGINTBIGINTBIGINTINTEGERNUMBER(19)
Float64DOUBLE PRECISIONDOUBLEFLOATREALBINARY_DOUBLE
DecimalNUMERICDECIMAL(38,10)DECIMAL(38,10)NUMERICNUMBER
StringTEXTTEXTNVARCHAR(MAX)TEXTCLOB
BytesBYTEALONGBLOBVARBINARY(MAX)BLOBBLOB
DateDATEDATEDATETEXTDATE
TimeTIMETIMETIMETEXTTIMESTAMP
DateTimeTIMESTAMPDATETIMEDATETIME2TEXTTIMESTAMP
DateTimeTzTIMESTAMPTZDATETIMEDATETIMEOFFSETTEXTTIMESTAMP WITH TZ
JsonJSONBJSONNVARCHAR(MAX)TEXTCLOB
UuidUUIDCHAR(36)UNIQUEIDENTIFIERTEXTRAW(16)
ArrayJSONBJSONNVARCHAR(MAX)TEXTCLOB

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:

ModeBehavior
off(default) Always use the generic multi-row INSERT path. v1 baseline.
autoTry the native path; on BulkUnavailable emit one stderr warning and fall back to generic INSERT for that batch.
onRequire 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).

DestinationNative pathCommon BulkUnavailable triggers
PostgresCOPY <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).
MSSQLtiberius::Client::bulk_insert (TDS bulk-load token)Target is not a base table; Invalid object name.
MySQLLOAD DATA LOCAL INFILE via a per-call infile handlerServer-side local_infile=OFF (default in MySQL 8.0+ — set local_infile=ON to enable).
Oracleoracle::Batch (array DML via ODPI-C)ORA-01031 insufficient privilege; ORA-00942 table does not exist; Instant Client missing.
SQLiteNo native loader. --bulk-native=on returns a hard error.

Format choices

  • Postgres defaults to FORMAT TEXT. Pass --copy-format binary to opt into FORMAT BINARY (Postgres-only flag; ignored elsewhere). Binary streams via tokio_postgres::binary_copy::BinaryCopyInWriter; each value is bound through its ToSql impl using the destination PG type derived from the source column’s TypeHint. 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 off is a usage error.
    • Source columns whose TypeHint is Other (rare; arises when a backend driver can’t classify a custom type) cannot be bound in binary mode and surface as BulkUnavailable so the dispatcher can fall back under --bulk-native=auto.
  • 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 one bulk_insert_rows call — a hostile LOAD DATA LOCAL INFILE '/etc/passwd' typed into ferrule query on 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'
  • --include defaults to “everything”; multiple --include patterns OR together.
  • --exclude is 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 = replica on 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 --yes clears each destination table once at the top of its own copy — --yes is consulted once for the run, not per table.
  • --if-exists skip|upsert requires a PK per table; tables without a PK hard-error at their own step (see [Conflict handling] (#conflict-handling)).
  • --bulk-native auto|on applies per table; skip|upsert still force the generic path table-by-table.
  • --atomic wraps 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:

SharedSource-onlyDestination-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:

  1. Opens an SSH session to ec2-user@bastion.example.com:22 using the key at ~/.ssh/id_ed25519.
  2. Asks the bastion to open a direct-tcpip channel to db.internal:5432.
  3. Hands that channel directly to tokio_postgres via connect_raw. The Postgres protocol — and TLS, if your URL has sslmode=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 $USER and the port falls back to 22 — not to whatever the profile said. “One flag, one tunnel target.”
  • --ssh-key <path> — overrides ssh_key independently. 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:

  1. --ssh-key <path> (CLI) or ssh_key = "..." (profile).
  2. FERRULE_<NAME>_SSH_KEY=<path> env var (where <NAME> is the uppercased connection name with -_).
  3. ~/.ssh/id_ed25519.
  4. ~/.ssh/id_rsa.
  5. 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-key once per shell session, then ferrule will route signing requests through the agent.
  • Decrypt the key on disk: ssh-keygen -p -f ~/.ssh/encrypted-key removes 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

OutcomeIn a TTYIn a script / CI
Host known, key matchesSilent acceptSilent accept
Host key mismatchFatal errorFatal error
Unknown hostPrompts once for TOFUFatal 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

SymptomLikely causeFix
connect to <host>:<port>: Connection refusedBastion isn’t listening on that port, or you’re not on its allow-listConfirm 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 bastionConfirm with plain ssh -i <key> user@host
SSH agent at <sock> has no identities loadedAgent is running but emptyssh-add ~/.ssh/id_ed25519
load SSH key from <path>: ...Wrong passphrase or corrupted keyCheck the passphrase or regenerate the key
SSH key <path> is encrypted. Passphrase prompting requires an interactive terminal.Encrypted key in a non-interactive contextUse the agent or decrypt on disk (see above)
SSH host <host>:<port> is not in ~/.ssh/known_hosts.Unknown bastion in CI / scriptPre-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 SQLiteThe URL is a sqlite:// schemeDrop --ssh-tunnel for sqlite
Long hang then “connection failed”DB host unreachable from the bastionConfirm 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:

  1. Opens a TCP connection to proxy.corp.example.com:8080.
  2. Sends CONNECT db.internal:5432 HTTP/1.1.
  3. After the proxy returns 200 Connection established, negotiates the Postgres protocol through the tunnelled stream.

Proxy configuration layers

Five layers, first hit wins:

  1. --proxy-url <URL> CLI flag.
  2. proxy_url = "..." in the profile (.ferrule.toml).
  3. FERRULE_<NAME>_PROXY_URL=<URL> env var (where <NAME> is the uppercased connection name with -_).
  4. ALL_PROXY, HTTPS_PROXY, or HTTP_PROXY env vars.
  5. 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

BackendProxy path
Postgreshttp_connecttokio_postgres::Config::connect_raw (direct stream)
MySQLhttp_connect per accepted connection → local TCP listener → driver
MSSQLhttp_connect per accepted connection → local TCP listener → driver
Oraclehttp_connect per accepted connection → local TCP listener → driver
SQLiteNo-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.com matches, example.com does 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:

FieldDescription
tsRFC 3339 timestamp (UTC)
connConnection target. For raw URLs this is DatabaseUrl::redacted() — passwords are scrubbed before recording. Registry names and SQLite paths pass through unchanged.
commandSubcommand name (query, copy, tables, …)
sqlThe 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_msEnd-to-end wall-clock in milliseconds
rowsSample count for --bench, otherwise NULL (the dispatch hook can’t see the per-command row count)
exit_code0 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
errormiette 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 separate results.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.

PathCached?
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 Nno — --bench implicitly disables cache
ferrule query db ... --explainno
ferrule query db ... --watchno
ferrule query db ... --dry-runno
ferrule query db ... --daemonno
ferrule query db ... --no-cacheno — 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 = falseno — 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-cache set
  • --bench N set (implicit; --bench measures the database, not the cache)
  • --explain set
  • --watch or --watch-file set (each tick reopens the loop)
  • --dry-run set
  • --daemon set (the daemon path returns a pre-rendered payload)
  • The SQL is modifying (INSERT/UPDATE/DELETE/MERGE/DDL — anything ferrule_core::explain::is_modifying flags)
  • The SQL is a multi-statement batch (only single-statement SELECT results are cached; insert is gated on results.len() == 1)
  • No --cache flag AND [cache] default_ttl is "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:

FailureBehaviour
Lookup error (DB locked, etc.)Run the real query. --verbose prints a stderr trace.
Insert errorPrint result as normal. --verbose prints a stderr trace.
Corrupted results.dbOpen 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 tar bundles 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]:

  1. DDL-elsewhere invalidation. A third client ALTERs a cached table; ferrule silently returns stale rows until TTL expires.
  2. Eviction on local writes. When ferrule itself runs modifying SQL against a cached connection, should it invalidate matching entries? Today, no.
  3. Cross-machine shared cache. Redis / S3 backend behind the same CacheDb trait.
  4. Multi-statement caching. Round-tripping Vec<StatementResult> is harder than QueryResult.
  5. Prepared-statement cache (daemon mode). Shared prepared-stmt + result cache once full daemon mode lands.
  6. Size budget. Today unbounded until prune() fires against max_rows. Add max_size_mb analogous to slow-log max_size.
  7. 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

BackendURL schemesFeature flagDriver
PostgreSQLpostgres, postgresqlpostgres (default)tokio-postgres + rustls
MySQLmysql, mariadbmysql (default)mysql_async
MSSQLmssql, sqlserver, tdsmssql (default)tiberius
SQLitesqlitesqlite (default)rusqlite (bundled)
Oracleoracleoracle (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 a cc build (statically linked, no system library), so it is C-free in the no-system-linkage sense but is off the Postgres + MySQL floor above.
  • mssqltiberius 0.12’s stable release only offers native-tls, which links the platform OpenSSL; the mssql feature therefore links a C system library and is excluded from the deny.toml firewall graph.
  • oracle — needs the external ODPI-C Instant Client at runtime (see above); it is dlopen’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 libpq required.
  • 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 Value types 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 via mysql_async native 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.
  • DATETIMEOFFSETValue::DateTimeTz; BITValue::Bool; UNIQUEIDENTIFIERValue::Uuid.
  • No native JSON type — store JSON in NVARCHAR(MAX) and ferrule returns it as either Value::Json (if it parses) or Value::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 rusqlite with the bundled feature; 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 exec from the driver, but ferrule doesn’t surface multi-statement batches.
  • Schema introspection uses pragma_table_info and sqlite_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 BOOLEAN until 23c — most schemas use NUMBER(1).
  • No native UUID — use RAW(16) DEFAULT SYS_GUID().
  • JSON is CLOB with an IS JSON check constraint (12c+).

Setup details, including the libaio symlink workaround for Ubuntu 24.04+, live in Troubleshooting.

TLS posture summary

BackendTLS by default?Requires it?Self-signed dev cert
PostgreSQLNegotiated; client decides?sslmode=require (encrypt) / verify-full (full verify)?sslmode=require + --insecure
MySQLOff unless server demands; rustlsserver-side require_secure_transport--insecure
MSSQLAlways negotiated; cert often self-signed(default)?trustServerCertificate=true or --insecure
SQLiteN/A — local file or memory
OracleServer-configured (TNS)TNS listener configTNS-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

BackendBatch via ;Notes
PostgreSQLFirst-class; result sets and DML row counts both reported
MySQLVia mysql_async multi-result API
MSSQLVia TDS row-set framing
SQLiteUse a script via --file if needed; one statement per query call
OracleSemicolon-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 ValuePostgresMySQLMSSQLSQLiteOracle
BoolBOOLEANBOOLEAN (TINYINT(1))BITINTEGERNUMBER(1)
Int64BIGINTBIGINTBIGINTINTEGERNUMBER
Float64DOUBLE PRECISIONDOUBLEFLOATREALBINARY_FLOAT
DecimalNUMERICDECIMALDECIMALNUMERICNUMBER
StringTEXT / VARCHARVARCHARNVARCHARTEXTVARCHAR2
BytesBYTEABLOBVARBINARYBLOBRAW
DateDATEDATEDATETEXT (ISO 8601)DATE
DateTimeTIMESTAMPDATETIMEDATETIME2TEXT (ISO 8601)TIMESTAMP
DateTimeTzTIMESTAMPTZTIMESTAMPDATETIMEOFFSETTEXT (ISO 8601)TIMESTAMP WITH TIME ZONE
JsonJSONB / JSONJSONNVARCHAR(MAX) (JSON-shaped)TEXTCLOB (with IS JSON)
UuidUUIDCHAR(36)UNIQUEIDENTIFIERTEXTRAW(16)
ArrayT[] (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:

  1. Re-run with -v / --verbose to see the resolved URL and SQL.
  2. Add --timing to see whether you’re stuck on connect, query, or format.
  3. Match the symptom to one of the entries below.

Exit codes (echo $?) tell you which class of result you hit:

CodeMeaning
0Success
1Notable result — ferrule diff found differences, future --expect-rows-style assertions. GNU diff / grep convention. Not an error
2Usage / argument error (matches clap’s parse-error exit)
3Connection error (TLS, auth, network)
4Query 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 port first; if nc can’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 omit sslmode to negotiate) — verify-full checks the chain and the hostname.
  • For PostgreSQL with a private CA, install the CA into the system trust store (rustls reads /etc/ssl/certs on Linux). There is no per-connection CA flag yet.
  • For MSSQL with a self-signed cert (the docker test image), ?trustServerCertificate=true accepts it. --insecure is the blanket equivalent.
  • Don’t reach for --insecure until 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 -v to 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>_PASSWORD env 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_url in .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 -Y forwards 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-keychain once 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 0 to disable paging for this call, or
  • Split the statements into separate ferrule query calls, or
  • Set [default] limit = 0 in .ferrule.toml if 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] or dbo.users.
  • SQLite: tables are case-sensitive but unquoted identifiers are case-folded — SELECT * FROM Users and users may 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):

  1. Download Basic from Oracle’s site (license click-through required).
  2. unzip -q instantclient-basic-linux.x64-*.zip -d ~/opt/oracle
  3. export LD_LIBRARY_PATH="$HOME/opt/oracle/instantclient_23_26:$LD_LIBRARY_PATH"
  4. 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
    
  5. 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]

See EXPLAIN, Dump, and Watch.

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.

FlagDescription
-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
--timingPrint connect/query/format timing to stderr
-v, --verboseEcho resolved (redacted) URL and SQL to stderr

Connection flags

Available on every command that opens a connection.

FlagDescription
--insecureDisable TLS certificate-chain and hostname verification. Prints a warning to stderr. See Security
--daemonRoute the request through the per-user connection-pooling daemon (must be running — ferrule conn start). See Daemon

Password flag (query and watch only)

FlagDescription
-p, --password <pwd>Pass the password explicitly. Insecure — leaks to shell history. Prefer password_url in Configuration

Query-only flags

FlagDescription
--file <path>Read SQL from a file
--stdinRead SQL from stdin
--param NAME=VALUESet a ${NAME} placeholder. Repeat for multiple parameters
--param-file <path>Load parameters from a JSON object file
--explainWrap the SQL in EXPLAIN before sending
--dry-runPrint the resolved SQL and URL; do not connect
--filter <expr>JMESPath expression to filter/reshape JSON output before printing. Implies --format json
--watchRe-run the query periodically (delegates to watch command)
--watch-interval <secs>Polling interval when --watch is set. Default: 5

Explain-only flags

FlagDescription
--analyzeExecute the statement and collect runtime statistics. Silently downgraded for INSERT / UPDATE / DELETE / DDL to avoid side effects

Dump-only flags

FlagDescription
--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

FlagDescription
-t, --table <name>Target table (inferred from file stem if omitted)
-f, --format <fmt>csv or json (inferred from extension if omitted)
--create-tableCreate the target table from the JSON schema before loading. JSON only

Watch-only flags

FlagDescription
-i, --interval <seconds>Polling interval. Default: 5. Minimum: 1
--max-iterations <n>Stop after <n> iterations
--diffOnly 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-errorTerminate on the first connection or query failure
--bellRing the terminal bell (ASCII BEL) when --diff detects a change

Export-only flags

FlagDescription
--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

FlagDescription
--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

FlagDescription
--background (on conn start)Fork to background and detach

Exit codes

CodeMeaning
0Success
1Notable 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.
2Usage error (invalid CLI arguments, malformed SQL parsing). Matches clap’s default exit code for argument-parse failures
3Connection error (network, TLS, authentication, backend unavailable)
4Query error (SQL syntax, constraint violation, schema mismatch)

Environment variables

VariableEffect
FERRULE_<NAME>_PASSWORDLegacy password fallback for connection <name> (uppercased; hyphens become underscores). productionFERRULE_PRODUCTION_PASSWORD
RUST_LOGEnable 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

FilePurposePath
Global configPer-user defaults, connection profiles~/.config/ferrule/ferrule.toml
Project configProject-local overrides./.ferrule.toml
Connections registrySaved name → URL (managed by ferrule conn add)~/.config/ferrule/connections.toml
BookmarksSaved query library~/.config/ferrule/bookmarks.toml
REPL historyPersistent history file~/.cache/ferrule/history
Daemon socket (Unix)Per-user IPC for --daemon mode~/.cache/ferrule/daemon.sock
Daemon PIDPID 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.