DBGrep

A CLI that searches across every table in a database at once

DBGrep cover

Key learnings

CLI design with Typer, including subcommands, global options, and config-file precedence

Backend-agnostic querying through SQLAlchemy rather than database-specific SQL

Running the same test suite against PostgreSQL, MySQL, and SQLite with a parameterised fixture

Integration testing against real database instances using Testcontainers

Description

DBGrep is a command-line tool for finding a value in a database when you do not know which table it lives in. Point it at a connection string, give it something to look for, and it searches across the schema and prints every table that matched (the database equivalent of grep -r).

It has two commands. inspect reports the structure of a database: tables, columns, primary and foreign keys, and indexes, either for everything or scoped to one table with --in.

inspect prints a table schema with types, nullability and keys
inspect fkeys lists the foreign key relations of every table

search looks for values, with exact matching, LIKE patterns, and numeric greater/less comparisons that can be combined and are ANDed together. It can run across the whole database, or be scoped to a table or a single column with --in users.name. Global options such as the connection and output format come before the command, and command options come after it:

Search every table for a value
dbgrep --db-url "postgresql://user:pass@host/db" search --like "%Research%"
Combine filters within one table
dbgrep --config config.json search --in employees --like "%john%" --greater 50000
Inspect a table and write the result as JSON
dbgrep --config config.json --format json --output users.json inspect --in users

Both commands render through the same formatter, which prints a table, JSON, or YAML, optionally syntax-highlighted, paged, or written straight to a file.

A combined greater and less filter on one column, output as JSON
A LIKE search on one column, output as YAML

The connection can live in a JSON config file instead of a flag. It needs a db_url key and can optionally set defaults for the formatter:

config.json
{
  "db_url": "postgresql://user:pass@host/db",
  "formatter": { "format": "yaml", "theme": "monokai", "paging": false }
}

Everything goes through SQLAlchemy with no backend-specific SQL anywhere in the query layer, so the same commands work against PostgreSQL, MySQL, and SQLite.

Reflection

I worked on this with Tobias and two others, and my part was mostly the surface: the CLI entry point, the formatter, and the configuration handling, plus most of the documentation.

The formatter turned out to be the more interesting half. It started as "print a table" and accumulated real requirements: three output formats, syntax themes for the structured ones, a pager for results too long for a screen, and an option to write to a file instead. Each of these arrived as a separate flag, and the naive version ended up with format decisions scattered across every call site. Pulling the configuration into the formatter's constructor, so callers just hand it data, was the refactor that made the rest of the features cheap to add.

Configuration precedence was subtler than expected. A connection can come from a JSON config file or a --db-url flag, and the formatting options can come from either as well. Getting the layering right (file provides defaults, explicit flags override them, and mutually exclusive options fail loudly rather than silently picking one) took more iterations than the feature deserved, mostly because Typer's default values make "the user didn't pass this" and "the user passed the default" hard to tell apart.

The bug I remember best was a one-word fix. Combining --like "%john%" with --greater 50000 was ORing the two conditions instead of ANDing them, so a filter that should have narrowed the results widened them instead. It is the kind of mistake that produces plausible-looking output and no error, which is exactly why the test suite mattered.

That test suite is the part of the project I would point at. A database_url fixture runs every test against PostgreSQL and MySQL in Testcontainers and against SQLite in a temp file, so each test is written once and executed three times. It caught genuine divergences between backends that would otherwise have shipped, and it is the reason the "no database-specific code" rule held up rather than quietly eroding.

Last updated: Nov 11, 2026