LibreDB Studio is an MIT-licensed, self-hosted database IDE that runs in the browser. Instead of every teammate installing a desktop client, you run one copy next to the database (docker run -p 3000:3000 libredb/libredb-studio, a Helm chart, or an npm package) and open a URL. The README lists 16 engines today: PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino, and Apache Cassandra. This post is about the two parts that took the most work: turning very different result shapes into one grid, and turning each engine's own EXPLAIN output into one plan view.
One grid, many result shapes
The relational engines are the easy case: rows and columns arrive with column metadata. The problem is everything else. Redis has no rows at all: GET returns a scalar, HGETALL a map, LRANGE a list, ZRANGE members with scores. MongoDB and Couchbase return ragged JSON documents where each row can carry different fields. Elasticsearch and OpenSearch return hits with _source plus metadata, and aggregations are a separate tree that looks nothing like a result set. Cassandra returns rows, but wide, with collections inside cells.
We do not speak most of these wire protocols ourselves. We use the maintained driver per engine (pg, mysql2, the Mongo driver, the ES and OpenSearch clients, the Cassandra driver, the Trino client, and so on) and normalize their output. The unification work lives in a shape layer above the drivers, not in re-implementing protocols, and being honest about that boundary is part of why the layer stays small.
The internal representation is deliberately dumb: every result is a list of rows, every row is an ordered map from column name to a typed cell. Each engine has a provider whose only job is to flatten its native response into that shape. The grid knows the internal shape and nothing about the wire format.
Column discovery is where document stores fight back. Relational engines hand you columns up front; Mongo and ES do not. For those we sample the current page, take the union of keys in first-seen order, and when a later row lacks a key we render an explicit "absent" marker rather than null. In a document store "field is missing" and "field is null" are different facts, and we did not want the grid to quietly turn one into the other. Nested objects stay as an expandable JSON cell instead of exploding into dotted columns, because arrays make that explosion unreadable fast.
Every cell carries two type tags: the engine-native type and a normalized category (number, string, temporal, binary, json, null). The grid formats by category but shows the native type on hover, so a Postgres numeric and a ClickHouse Decimal both right-align while you can still see what each one actually is.
Where the grid tells you nothing rather than something wrong
Cassandra does not publish a reliable total row count for a query without a full scan, so the grid shows no total instead of a wrong or expensive one; paging uses the driver's paging state. Trino is a query engine over other sources, so it declares no keys or indexes: our schema tree lists tables and columns, and the index and constraint sections are simply empty for Trino, not faked. Three engines are effectively read-only in Studio because their SQL surface has no UPDATE or CREATE TABLE in the form we would need: Druid, Elasticsearch, and OpenSearch. You can query and explore them; the write and DDL affordances are disabled for those engines rather than shipped half-working. DuckDB has a different honest constraint: it admits exactly one operating-system process against a file, refused even in read-only mode, so a second Studio instance cannot open a database this one already holds. That is a property of embedding DuckDB, not something we can paper over, so we surface it plainly instead of hanging.
One plan view, many EXPLAIN formats
The visual plan was the other hard part, because every engine emits EXPLAIN in its own format. Postgres gives EXPLAIN (FORMAT JSON), a nested node tree with costs and, under ANALYZE, actual times. MySQL gives a different nested JSON built from query_block and nested_loop. SQL Server emits showplan XML. Oracle gives flat plan-table rows with id and parent_id that you reassemble into a tree yourself. ClickHouse has its own indented text and a JSON form. Mongo's explain() returns a stage tree of COLLSCAN, IXSCAN, and FETCH. ES and OpenSearch have the profile API, a shards to queries to children tree timed in nanoseconds. Druid's native EXPLAIN PLAN FOR returns a row containing a JSON string that describes a native query, not relational operators. DuckDB gives EXPLAIN (FORMAT JSON) physical plan trees, and libSQL, which is SQLite reached over a network protocol rather than on disk, gives the same EXPLAIN QUERY PLAN as file SQLite.
We defined one plan node: an id, a label, a small set of common metrics (estimated rows, actual rows, and cost or time when the engine provides them), an open details bag for engine-specific fields, and children. Each engine has a parser from its native EXPLAIN into that node, and one collapsible tree renders all of them. Oracle's flat rows we rebuild by linking each child to its parent id. For Druid we pull the JSON string out of the EXPLAIN row and walk the native query, labeling scan, groupBy, and timeseries stages so a Druid plan renders in the same widget as a Postgres plan.
The honest cost is that normalizing to a common node loses some engine-specific richness in the main view, so the raw EXPLAIN stays one click away. And cost is not comparable across engines: a Postgres cost unit and a Mongo execution time are different things, so we label every metric by its source and never put them on one scale or invent a number for an engine that produces none.
Transport security cuts across all of it
Two pieces sit below every engine. If an SSH tunnel is configured, it is opened before the engine provider is allowed to connect, and the driver connects to the local end of the tunnel; that path is shared, not reimplemented per driver. TLS is a single panel (CA, client cert, verify mode) that we map onto each driver's own config, since no two drivers accept the same options. It is honored today by PostgreSQL, MySQL, SQL Server, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, and Trino; where a driver cannot take it, the panel is disabled rather than shown as if it worked.
The deploy-once trade-off
Because you deploy Studio once and the team opens a URL, the server holds the connection secrets. That is the point: nobody keeps a copy of production credentials on a laptop. But it also means the Studio server is now worth attacking. That is why RBAC, SSO over OIDC, and a full query audit trail all ship in the MIT build with nothing held back for a paid tier: a shared server needs access control and a record of who ran what. If you would rather each person hold their own credentials, a desktop client is the better fit. We chose the shared-server trade-off on purpose, and you should adopt it knowing the cost.
A few smaller notes: the editor is Monaco with schema-aware autocomplete and multi-tab, plus ER diagrams, schema diff, and migration SQL generation. NL2SQL uses your own model key (Gemini, OpenAI, Claude, or a local model); we host no model and your schema and queries do not pass through us. The commercial product, LibreDB Platform, is separate and funds this work.
Code is at github.com/libredb/libredb-studio (about 399 stars) and there is a live demo at app.libredb.org. The README is the source of truth for the current engine list and what each one supports. We would genuinely like feedback on the normalization choices, especially the lossy or disabled ones called out above. If you run one of these engines and our provider gets something wrong, please tell us.
Top comments (0)