港 - harbour. Where connections dock and wait to be dispatched.

An independent PostgreSQL client for Erlang.

Status

Early, and usable end to end: codecs, protocol, authentication with channel binding, TLS, connections, queries, transactions, a pool, LISTEN/NOTIFY, telemetry and logs. Nothing runs on it in production yet, and the API is not stable. Read the design guide before depending on it.

Install

{deps, [{minato, "~> 0.1"}]}.
{ok, _Pid} = minato:start_pool(main, #{
    size => 10,
    connection => #{host => "localhost", user => ~"minato",
                    password => ~"minato", database => ~"minato_test"}
}),

{ok, #{rows := [{42}]}} = minato:query(main, ~"SELECT $1::int4", [42]),

{ok, done} = minato:transaction(main, fun(Conn) ->
    {ok, _Result, Written} = minato_query:query(Conn, ~"INSERT INTO t VALUES ($1)", [1]),
    {ok, done, Written}
end).

Getting started is the ten minute version.

What is different about it

  • A slow query costs a query, not a connection. When a statement passes its deadline minato sends CancelRequest and reads the cancellation through, so the answer is the server's own 57014 and the connection goes back to the pool. A read timeout on its own can only end the wait: the server carries on running the query and the connection is left with an answer coming that nobody will read.
  • A COMMIT the server turns into a ROLLBACK is reported as one. PostgreSQL answers COMMIT with ROLLBACK when the transaction had already failed. Nothing was written, and a client that reads only "the COMMIT completed" reports success for work that was thrown away - which is how a job queue runs a job twice.
  • The SCRAM exchange is bound to the TLS session. SCRAM-SHA-256-PLUS with tls-server-end-point, on by default under TLS. Without it a man in the middle holding a certificate you accept can relay the whole exchange and keep the session, without ever learning the password.
  • TLS has no prefer mode. A server that declines is refused, because declining is the one message an attacker on the connection can always produce.
  • A pool opens one connection and grows. min_size is 1, not size: a node with twenty pools should not want two hundred connections the moment it boots, and a pool that cannot connect still says so at start up. A checkout against a pool that holds nothing and cannot connect fails immediately rather than queueing every caller for the checkout timeout.
  • Result sets are framed in bulk. One read with the remainder carried forward, never a header read then a payload read: 61 reads over 5000 rows rather than 10,006. See the benchmark.

Guides

  • Getting started - pool, query, transaction, listen
  • Configuration - every option, its default, and why
  • Types - what a value comes back as, and what happens to a type minato has no codec for
  • Security - TLS, channel binding, credentials, and what never reaches a log
  • Observability - the events, the logs, and what to alert on
  • Design - the four decisions everything else follows from

Testing

rebar3 eunit needs nothing installed: unit tests and PropEr round trips for every type in both wire formats, byte for byte tests of every protocol message against the documented format, framing properties that cut a stream at every byte and require it back whole, and SCRAM tampering properties stated over every byte position rather than as a few examples. The protocol round trips do not compare an encoder against itself: frontend messages are read back by a separate reader written from the same documentation, and backend messages are built by a separate writer.

rebar3 ct needs a PostgreSQL:

docker compose -f test/docker-compose.yml up -d
rebar3 ct
docker compose -f test/docker-compose.yml down -v
  • differential - every type against pg_types as a black box oracle and against PostgreSQL 17 as the judge, in both wire formats
  • round trip - the same corpus through minato's own connection and pool, as a bound parameter and as a column of a real table
  • properties - result sets of any size, any number of parameters, nulls in any position, UTF-8 of any content, bytea of any size, tuples against maps, prepared against unprepared
  • authentication - the whole SASL exchange over a real socket, including recomputing the verifier PostgreSQL stored in pg_authid
  • TLS - a verified handshake against a second server with ssl=on, whose certificate authority it generates at start up so no key is in this repository, a certificate for another host refused, and pg_stat_ssl asked whether the session is really encrypted
  • soak - concurrent workers checking their own answers, with backends killed underneath them, and the pool measured at rest afterwards

CI runs all of it on OTP 28 and 29.

Requirements

Build and test on OTP 28 or later. OTP 29 is the intended floor for released versions; the minimum_otp_vsn gate reads 28 because the pre-push checklist runs elp lint and elp eqwalize-all and ELP publishes no OTP 29 binary.

Prior art

minato is an independent implementation. It contains no code from any other project and is not a fork of one. Wire formats are taken from the PostgreSQL documentation and the documented binary send and receive representations, not from any existing client.

These projects were studied as architecture and are acknowledged as influences: pgo, epgsql, Postgrex, asyncpg and pgx.

pg_types and pgo are test profile dependencies only: pg_types as a differential oracle, pgo as a transport that carries minato's bytes to a real server. Neither is shipped at run time and neither is read as a source of implementation.

Licence

Apache-2.0. Copyright 2026 Widgrens IT AB (org.nr 559241-2752). See LICENSE.