A native, open source desktop database manager for PostgreSQL, written in Go.
Fast, lightweight clients (TablePlus, Postico, Beekeeper) can't compare structure, compare data, transfer between databases, or run backups. The tools that can (Navicat, DBeaver PRO, dbForge) are heavy, expensive, or both. Hermes exists to fill that gap: the four operations people buy a license for — structure sync, data sync, data transfer, and integrated backup/restore — in a native app that is fast and free.
The bet is focus: do for one engine what the competition tries to do for twenty.
Pre-alpha — under active development. You can open several servers at once and save each connection — with its password in your system keychain where there is one, and held for the session where there is not — mark one as production or as read-only, and browse what is on it — databases, schemas, tables, views and sequences, with an object's properties and its DDL in a pane of their own. There is no SQL editor and no backup yet. This README grows with every feature shipped: anything documented below with an example works. Anything in the Roadmap section does not.
Reading a schema out of pg_catalog and writing it back as DDL is what the DDL tab shows,
and it is what the structure diff will be built on. The diff itself does not exist yet: it is
named under Roadmap, not under Features, and it moves when there is something you can run.
- Nothing destructive without a preview. Every operation that changes structure or data produces SQL you can read, edit, and save before it runs.
- Whatever the wizard does, the CLI does too. Every operation is a serializable profile that runs headless — the same file works on your machine and in CI.
- Nothing blocks the UI. Every long operation is a cancelable job with progress.
- Fast is a requirement, not a goal. Startup under 1.5s; first page of a 50M-row table in under 500ms; diff of 1,000 tables in under 20s.
- A false positive in the diff is a bug. Comparing a schema against itself must report zero differences.
-
PostgreSQL 12 or newer on the server you want to manage. Every change is tested against PostgreSQL 12, 13, 15, 16, 17 and 18.
-
PostgreSQL client tools (
pg_dump,pg_restore,psql) for the backup and restore features. Hermes does not bundle these binaries: it detects your existing installation and warns you when the client version is incompatible with the server. Install them with your platform's package manager:# Debian / Ubuntu sudo apt install postgresql-client # macOS (Homebrew) brew install libpq # Windows (winget) winget install PostgreSQL.PostgreSQL
Every release publishes binaries plus SHA256SUMS on the
releases page:
| Platform | Architecture | Artifact |
|---|---|---|
| Linux | x86-64 | hermes-linux-amd64 |
| macOS | Apple Silicon | hermes-darwin-arm64 |
| Windows | x86-64 | hermes-windows-amd64 |
Each one ships alongside a hermes-cli-* binary of the same platform. Intel Macs and
arm64 Linux are not published yet — build from source there. Signed installers arrive
with version 1.0.
Check what you have:
$ hermes --version
hermes v0.1.0 (a1b2c3d, 2026-08-30T12:00:00Z, linux/amd64)The desktop app shows the same version in the status bar at the bottom of the window, with the commit and the build date in its tooltip — so a bug report can always say which build it is about.
Requires Go 1.25+, Node.js 22.13+, and the Wails v3 CLI.
git clone https://github.com/gsoares85/hermes.git
cd hermes
make build # frontend and every binary
make dev # the desktop app, in development mode
make package # package for the current platformmake help lists every target.
Four regions, and each answers one question.
┌────────────────────────────────────────────────────────────────────┐
│ Hermes New connection Disconnect billing PRODUCTION │
├───────────────────┬────────────────────────────┬───────────────────┤
│ CONNECTIONS + │ ▣ billing × ▢ staging × │ Table │
│ ├────────────────────────────┤ daily_revenue │
│ billing PROD ⚙ │ │ │
│ staging ⚙ │ │ │
│ local ⚙ │ Pick an object in the │ Column Type │
│ │ navigator. │ id bigint │
│ ▾ analytics │ │ … │
│ ▾ reporting │ Its columns and the DDL │ │
│ daily_reven… │ that would build it │ Constraints │
│ ▸ public │ appear on the right. │ Indexes │
│ │ │ │
│ reporting@db:5432 │ │ │
│ · PostgreSQL 16.2 │ │ │
├───────────────────┴────────────────────────────┴───────────────────┤
│ ● connected analytics · reporting Hermes v0.4.0 · linux/amd64│
└────────────────────────────────────────────────────────────────────┘
navigator workspace details
A row in CONNECTIONS connects; the ⚙ beside it opens the form. The tabs across the workspace are the connections that are open — one each, with its own pools and its own cached catalog — and the one in front decides what the navigator lists, what the details pane describes and what the status bar says.
- The toolbar carries the connection in front and what can be done to it. Only actions that exist: no greyed-out buttons standing in for features nobody has built. A production connection colours it and puts a band across the top of the window, and neither scrolls away.
- The navigator lists the connections you have saved and, under the one that is open, its objects. Its foot says which server those objects came from.
- The workspace holds one tab per open connection. It is where a query editor and its results will go; until there is one, it says so rather than drawing an empty grid.
- The details pane describes the object you picked, with the DDL that would build it.
- The status bar says what the connection in front is doing.
Both side panes are resizable: drag the divider, or focus it and use the arrow keys, Home and
End. Enter puts a pane away and brings it back — the divider stays on screen when its pane
does not, because it is the way back. The widths are remembered between runs.
Press New connection, in the toolbar or at the head of the navigator, and fill in the form: host, port, user, password. The database name is optional. Leave it empty and Hermes connects to the server's maintenance database, then shows you every database that user is actually allowed to open — so you can look before you know what you are looking for.
Host db.example.com
Port 5432
Database (leave empty to see what is there)
User reporting
Password ••••••••
SSL mode require
Press Test connection to check the settings without keeping anything open, or Connect to open the connection. Once connected, the databases you can reach appear as a list; picking one fills the database field.
Connecting again does not cost you the connection you had. Each one gets a tab in the workspace, with its own pool per database and its own cached catalog, and the tab in front is what the navigator, the details pane, the toolbar and the status bar are all about. Closing a tab closes that connection.
While Hermes is working, a Stop button appears beside the others and is the only one that stays live. Every long step on this screen ends either in your keychain or in a server, and both can take as long as they like: reading a password on macOS or Linux can raise a dialog that waits for you, and a host that is not answering runs to the driver's own timeout. Stopping is never a failure — Hermes says the operation was stopped, and where the result is genuinely unknown, such as a save cut off part way, it says that instead of claiming either outcome.
[ Test connection ] [ Connect ] [ Save connection ] [ Stop ]
Working…
Already have a connection string? Paste it and press Fill the form:
postgres://reporting@db.example.com:5432/analytics?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem
Every part is read into the form — host, port, database, user, SSL mode and certificate paths. The password is deliberately not copied out of the URI, even when it carries one: Hermes tells you a password was present and asks you to type it, rather than keeping the secret in the window. A URI with no database at all works too:
postgres://reporting@db.example.com:5432
Give the connection a name and press Save connection. It appears at the head of the navigator, and from then on clicking it connects — that is what clicking the name of a server means, and it is one click from wanting to look at a database to looking at it.
Editing one is the occasional job, so it has a control of its own: the ⚙ beside the name opens the form again with its settings filled in. While a connection is opening, that control becomes a stop — a server can take as long as it likes to answer, and a way out that has to be waited for is not one.
Clicking a connection that is already open comes back to its tab rather than opening a second one. One server, one tab, whichever way you got there.
Forget is in that form rather than beside the name in the list. It takes the password out of your keychain and nothing can put it back, so it asks twice — and it is better pressed by somebody who has the connection open in front of them than by somebody aiming at a small target in a list.
The settings go in a file you can read, version and copy between machines:
| Platform | Connections file |
|---|---|
| Linux | ~/.config/hermes/connections.toml |
| macOS | ~/Library/Application Support/hermes/connections.toml |
| Windows | %AppData%\hermes\connections.toml |
version = 2
[[connection]]
id = 'b7f0c6e1-1a4d-4f2f-9d6a-2a1c8e0b3f55'
name = 'production — read only'
host = 'db.example.com'
port = 5432
database = 'analytics'
user = 'reporting'
sslmode = 'verify-full'
sslrootcert = '/etc/ssl/ca.pem'
environment = 'prod'
read_only = true
[connection.params]
application_name = 'hermes'
[[connection]]
id = '3c9a1e42-7b58-4d10-9f2e-6d4b0a7c1e93'
name = 'local'
host = 'localhost'
port = 5432
user = 'postgres'
sslmode = 'disable'There is no password field in this format, and no way to put one in it. A file that names
password at the top level is refused with an error saying so, rather than read as if the
password had taken effect. The same goes for the two free-form tables: password,
sslpassword and pgpassword are refused under [connection.params] and
[connection.options] too, on the way in and on the way out, because a key libpq would read a
secret from is a secret in a plain-text file whatever table it sits in. Committing this file to
a repository or mailing it to a colleague hands over no secret.
The id is what the password is filed under in your keychain, which is why it is a random
identifier rather than the name: renaming a connection, or moving it to a different host, keeps
the password attached to it.
version is read before anything else. This Hermes reads versions 1 and 2 and always writes 2,
so a file saved by an older build keeps working — it comes back with no environment and not
read-only, which is what a file with no field for either one means. A file from a newer Hermes
is refused with a message about the version rather than half-understood, because the file says
how you reach production and guessing at it is not an option.
In the password store your operating system already has:
| Platform | Store |
|---|---|
| Linux | the Secret Service of your desktop session — GNOME Keyring, KWallet |
| macOS | the login keychain |
| Windows | the Credential Manager, encrypted with DPAPI |
Hermes talks to each of them through its native interface, never by shelling out to a command-line tool. It reads a password only when it actually opens the connection, so listing your saved connections never triggers the authorization dialog that macOS and Linux can raise.
Four things Hermes never does with your password, each one covered by a test that fails the build:
- write it to disk in plain text — the keychain is the only place it is ever stored;
- put it in the connections file, which has nowhere to put one;
- print it in a log, an error message or a report;
- pass it on the command line of a program it starts, where every other process on the machine
could read it. The route
pg_dumpandpg_restorewill take is already built and tested: the environment, or a temporary password file created0600and removed afterwards. A file left behind by a crash, a kill or a power cut is removed by the next start-up, which asks the operating system whether anything still holds the file rather than trusting what its name says — so a Hermes running a backup keeps its file, and one that died does not.
Anything that could carry a connection string is redacted on the way out, so a message you paste into a bug report reads:
failed to connect to postgres://reporting:xxxxx@db.example.com:5432/analytics?sslmode=verify-full
Minimal desktops, containers and some window managers run no Secret Service. Hermes says so rather than failing quietly, and keeps working:
Hermes could not reach the Secret Service of this desktop session: the vault is unavailable:
no session bus. Passwords are kept in memory for this session only, and will be asked for
again the next time Hermes starts. Install and start a keyring — gnome-keyring or
kwalletmanager — and start Hermes again.
Your connections are still saved; only the passwords are forgotten when you quit. Nothing is written to disk to work around it — an encrypted file whose key sits next to it is plain text with extra steps. To get persistent passwords, install a keyring:
# Debian / Ubuntu
sudo apt install gnome-keyring
# Fedora
sudo dnf install gnome-keyring
# KDE
sudo apt install kwalletmanagerA connection can say what it is — dev, staging or prod — and whether it is allowed to
write at all:
Environment [ prod v ]
Read-only [x] The server is asked to refuse every statement that writes.
The form shows the value that goes in the file; the window draws it in words. A connection
labelled prod marks the window and not a panel: a band across the top and a PRODUCTION
badge in a coloured toolbar, both of which stay put while you move between the navigator, the
workspace and the details pane. The mark belongs to the connection rather than to the screen
showing it, so it looks the same everywhere the connection appears — in the list of saved
connections, on its tab, and above everything you do with it.
Read-only is enforced by the server, not by the window. A connection marked read-only is
opened with default_transaction_read_only on, so PostgreSQL is what refuses. Nothing Hermes
forgets to check can go around it, and nothing has to guess whether a statement writes by
reading the SQL — which is what any client-side version of this would have to do, and it would
be wrong about the first function that writes inside itself. Browsing to another database on
the same server opens a second connection, and it carries the mark with it. A connection that
sets the same parameter in its own session settings is refused rather than silently
overridden, because a protection you can argue with from two places is not one.
What the setting is not is a lock. It is the default of each transaction, so a session that
ran SET default_transaction_read_only = off would be writing again. Nothing in Hermes sends
arbitrary SQL today; the SQL editor, when it arrives, will have to refuse those statements on a
marked connection itself.
There is no SQL editor yet, so nothing in Hermes sends a write today: the gate is what that
editor will run into, and it is already proved against real servers rather than promised. The
test opens a marked connection, sends an INSERT, and requires both the server's refusal and
the explanation you would read:
This connection is marked read-only, so the server refused a statement that writes.
Hermes opens a connection marked read-only with default_transaction_read_only on, and the
server refuses every insert, update, delete and DDL inside it. The refusal comes from the
server, so nothing in this window went around it.
Clear the read-only mark on this connection and open it again, if writing to
db.example.com:5432 is what you meant to do.
A write refused on a connection you did not mark is a different message, because it is
fixed somewhere else: a standby accepts no writes, and a server, a database or a role can be
left with default_transaction_read_only on.
Both settings belong to the connection, so they go in the file with it and are still there after a restart:
[[connection]]
id = 'b7f0c6e1-1a4d-4f2f-9d6a-2a1c8e0b3f55'
name = 'production — read only'
host = 'db.example.com'
port = 5432
user = 'reporting'
environment = 'prod' # dev | staging | prod, omitted for a connection nobody labelled
read_only = trueOnce a connection is open, its object tree appears in the navigator, under the connection it belongs to: server → databases → schemas → tables, views and sequences.
▾ analytics database
▾ reporting schema
· daily_revenue table
· customer_lifetime view
· invoice_id_seq sequence
▸ public schema
▸ postgres database
Every level is read when you open it, and never before. Expanding a schema asks one question about that schema — names and kinds, no columns, no indexes, no constraints — so a schema with five thousand tables opens as quickly as one with ten, and nothing is loaded on the chance that you might look at it. The budget is a test rather than an intention: expanding a schema of 5,000 tables in under a second, checked in CI against the oldest server in the matrix, alongside a test that counts the queries a level costs so that "fast on my laptop" cannot hide a query per object.
Expanding never freezes the window. The node that is loading says so, the rest of the tree stays live, and collapsing a node stops the request instead of waiting for an answer nobody wants. The rows are virtualised from the first line, so the window draws what fits on screen whatever the level holds.
Type in Filter by name and the tree narrows as you type, highlighting the part that matched:
Filter by name [ invoice ] [ ] System schemas
What is already on screen is filtered on every keystroke, so the box keeps up with typing. Once the typing settles, the same text goes back to the server, so the objects of the schemas you open next are narrowed there too — a level of fifty thousand names is never carried across just to be thrown away here. Schemas themselves are never narrowed by it: hiding the schema that holds the table you are looking for would hide the answer along with the noise.
PostgreSQL's own schemas — pg_catalog, information_schema, pg_toast — are hidden until you
tick System schemas, which re-reads that level rather than revealing something already
fetched. Names are shown exactly as they were created, accents, spaces, capitals and all: a
schema called Relatórios Mensais reads as Relatórios Mensais, without the quotes SQL would
need. A database your role may not open reports why on its own node, and the databases beside it
stay usable.
Selecting an object fills the details pane on the right, which has two tabs:
Properties — the columns with their types and what is declared on them, the constraints and the indexes, and the facts that are not a column: that a table is unlogged, that it is a partition, that row security is on.
daily_revenue
analytics · reporting
[ Properties ] [ DDL ]
Column Type Null Default
id bigint not null
captured_on date not null
gross_cents bigint not null 0
note text
Constraints
daily_revenue_pkey: PRIMARY KEY (id)
daily_revenue_positive: CHECK ((gross_cents >= 0))
Indexes
CREATE INDEX daily_revenue_captured_on_idx ON reporting.daily_revenue USING btree (captured_on)
DDL — the statements that would build the object, written by the same generator the structure sync is built on:
-- Every name below is written bare, so this builds into whatever
-- schema the search path names. Change the line below to build elsewhere.
SET search_path TO "reporting";
CREATE TABLE "daily_revenue" (
"id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
"captured_on" date NOT NULL,
"gross_cents" bigint DEFAULT 0 NOT NULL,
"note" text,
CONSTRAINT "daily_revenue_pkey" PRIMARY KEY (id),
CONSTRAINT "daily_revenue_positive" CHECK ((gross_cents >= 0))
);
CREATE INDEX daily_revenue_captured_on_idx ON reporting.daily_revenue USING btree (captured_on);Every identifier is quoted and no statement names a schema, so the script builds the object wherever the search path points — which is what lets the same text be read here and applied somewhere else. It is the script the product would run, not a rendering of it made for reading: a test requires this tab to be identical to what the generator produces for the same object, rather than similar, so the screen can never quietly become a second DDL generator.
The tab shows one object, so a statement can name something that is not in it: a sequence owned
by a table is written with the OWNED BY that says so, and the table it names is not part of
the script. That is the object described truthfully rather than a script you can paste
anywhere — the whole-schema script the structure sync produces is a different thing, and it
carries everything it refers to.
When a connection fails, Hermes does not show you the driver's message. It shows what happened, why it probably happened, and what to do next:
The server has no rule allowing "reporting" to connect from this address.
pg_hba.conf decides who may connect from where, and it is consulted before the password is.
No rule matched this user, this database and this client address.
Add a pg_hba.conf line covering "reporting" from this address and reload the server.
This is a change on the server, not in these settings.
A wrong password, a missing pg_hba rule, a name that does not resolve, a closed port, a
firewall swallowing packets, a database that does not exist, a rejected certificate and a
dropped connection each get their own explanation, because each is fixed somewhere different.
The driver's original message is still there, one click away, for when you need it.
All six libpq sslmode values work — disable, allow, prefer, require, verify-ca and
verify-full — along with client certificates:
postgres://reporting@db.example.com/analytics?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem&sslcert=/etc/ssl/client.pem&sslkey=/etc/ssl/client.key
verify-ca checks that the server's certificate was signed by the authority you supplied;
verify-full also checks that the host you typed matches the certificate. Hermes never quietly
falls back to a weaker mode than the one you asked for.
Anything in a pasted URI that PostgreSQL understands as a session setting is carried through:
postgres://reporting@db.example.com/analytics?application_name=hermes&search_path=reporting,public
Connection settings such as connect_timeout or require_auth are carried too, and kept apart
from session settings — sending one as the other would quietly drop it.
Neither table will hold a password. password, sslpassword and pgpassword are refused under
[connection.params] and [connection.options], on the way in and on the way out, with an
error saying where passwords are actually kept:
[[connection]]
id = 'b7f0c6e1-1a4d-4f2f-9d6a-2a1c8e0b3f55'
host = 'db.example.com'
port = 5432
user = 'reporting'
[connection.options]
password = 'hunter2'~/.config/hermes/connections.toml: line 3, options.password: invalid connection:
options.password would put a password in the connections file in plain text — Hermes keeps
passwords in the keychain of the system
What is being built, in order:
Foundation — a cancelable job engine with progress reporting. Connecting, actionable failure diagnostics, keychain-backed passwords and lazy object-tree navigation are done.
Query and data — SQL editor with cancelable execution, a virtualized grid using keyset
pagination (no OFFSET on large tables), and inline editing that shows the UPDATE before it
runs.
Backup and restore — wizards over pg_dump and pg_restore, with selective object
restore and a local catalog of the backups you generate.
Structure sync — native schema diff over pg_catalog, producing a reviewable migration
script, a difference report, and a CI mode that turns the diff into a pipeline gate. Reading a
schema and writing it back as DDL is in place underneath, tested against PostgreSQL 12 through
18 by building a schema, reading it, writing it out, applying it to an empty schema and
comparing the two models.
Data transfer and sync — move and reconcile bulk data across environments, with a preview of everything that will change.
All of these operations are TOML profiles: versioned alongside your project and runnable without a GUI.
hermes run profiles/sync-staging.tomlRead CONTRIBUTING.md — it describes the whole process, including the version label every pull request needs.
The short version: TDD with a minimum of 85% statement coverage, Conventional Commits, one commit per phase, and a code review plus a security review on every pull request.
make lint # Go and frontend linters
make test # unit tests
make cover # unit tests plus the 85% floor
make audit # known vulnerabilities in both dependency trees
make test-integration # PostgreSQL 12, 13, 15, 16, 17 and 18 (needs Docker)
make bench # the performance budgets (needs Docker)Please report security vulnerabilities privately — see SECURITY.md.