The two-part handshake
You have a database URL. You want to run a query. You add tokio-postgres to your Cargo.toml, copy a snippet, and suddenly the compiler hands you a tuple instead of a single connection object. You get a Client and a Connection. You try to run a query, and nothing happens. You check the logs, and the process just sits there.
This is not a bug. It is a deliberate design choice that separates query construction from network I/O. Rust refuses to hide the fact that talking to a database involves two distinct responsibilities. One handle builds and sends requests. The other handle sits on the wire, reading bytes, parsing responses, and keeping the TCP socket alive. If you mix them, you block your async runtime. If you drop one, the other dies.
Think of it like a restaurant. The Client is your table and menu. You sit there, decide what to order, and hand the ticket to the kitchen. The Connection is the waiter. The waiter runs back and forth between your table and the kitchen. You do not want the waiter glued to your chair. You hand the waiter off to a separate worker so you can keep ordering food without blocking the entire dining room.
Why Rust splits the connection
Network sockets are stateful. They require a continuous event loop to read incoming data, handle timeouts, and manage keep-alive packets. If you tie that event loop to your application logic, every database call becomes a blocking operation. In an async runtime, blocking the executor starves every other task.
tokio-postgres solves this by returning two values. The Client owns the query builder, transaction manager, and result parser. It is cheap to clone and safe to send across threads. The Connection owns the actual TCP socket and the I/O event loop. It must be polled continuously. If you stop polling it, the socket buffers fill up, the database thinks you dropped offline, and your queries hang forever.
The split forces you to acknowledge the I/O boundary. You cannot accidentally block the connection task with heavy CPU work. You cannot accidentally drop the socket while holding a reference to a query result. The compiler enforces the separation at the type level.
Minimal working example
Here is the smallest program that connects, acknowledges the split, and exits cleanly.
use tokio_postgres::{NoTls, Client, Error};
/// Establishes a basic async connection to PostgreSQL and spawns the I/O task.
#[tokio::main]
async fn main() -> Result<(), Error> {
// Parse the connection string and initiate the TCP handshake.
// NoTls means we skip encryption for local development.
let (client, connection) = tokio_postgres::connect(
"host=localhost user=postgres password=secret dbname=mydb",
NoTls,
).await?;
// Spawn the connection task so it runs in the background.
// If you drop this task, the client becomes unusable.
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {e}");
}
});
// The client is now ready to run queries.
// We return early here just to show the setup works.
Ok(())
}
The connect function returns a Result<(Client, Connection<NoTls>)>. The ? operator unwraps the tuple. You immediately hand connection to tokio::spawn. The async move block takes ownership of the connection task and hands it to the runtime. Your main function keeps the client and continues.
What happens under the hood
When connect runs, the crate performs a sequence of steps. It resolves the hostname via DNS. It opens a TCP socket to port 5432. It sends the PostgreSQL startup message containing your username and database name. The server responds with authentication challenges. tokio-postgres handles the password exchange, scrambles the credentials, and negotiates the protocol version.
Once authentication succeeds, the crate splits the internal state. The Client holds a channel sender. The Connection holds the channel receiver and the raw socket. Every time you call client.query(), the client serializes the SQL and parameters into bytes, sends them through the channel, and waits for a response. The connection task reads from the channel, writes to the socket, reads the socket response, parses the PostgreSQL wire protocol, and sends the result back through the channel.
This channel-based architecture is why you must spawn the connection task. The channel has a bounded buffer. If the connection task is not running, the buffer fills up after a few queries. The next query blocks forever waiting for space. The runtime does not panic. It just waits. Your application appears to freeze.
A realistic setup
Production code rarely passes raw strings to connect. You parse environment variables, validate credentials, and handle TLS. You also want structured error handling instead of eprintln!.
use std::env;
use tokio_postgres::{Config, Error, NoTls};
/// Builds a configured client and returns it ready for queries.
async fn get_db_client() -> Result<tokio_postgres::Client, Error> {
// Read the connection string from environment variables.
// This keeps secrets out of your source code.
let conn_str = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
// Parse the string into a Config struct.
// This validates the format before attempting a network call.
let config = Config::from_str(&conn_str)?;
// Connect using the parsed configuration.
// We still use NoTls here for simplicity, but production
// code typically passes a Rustls or NativeTls connector.
let (client, connection) = config.connect(NoTls).await?;
// Spawn the connection task with a descriptive label.
// This helps when profiling async tasks later.
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("postgres connection task failed: {e}");
}
});
Ok(client)
}
A community convention worth noting: always spawn the connection task immediately after connect. Do not store the Connection handle in a struct alongside the Client. It creates false confidence. The moment you drop the struct, the connection task dies, and your client silently fails on the next query. Keep the spawn call right next to the connect call. Treat the connection task as a fire-and-forget background worker.
Pitfalls and compiler errors
The split API trips up beginners in predictable ways. Here is how the compiler catches them.
If you forget to spawn the connection task, the code compiles. The runtime does not complain until you run a query. The query hangs. You will see no compiler error here. This is a runtime lifecycle mistake. The fix is always the same: wrap connection.await in tokio::spawn.
If you try to move the Client into a spawned task without the connection task running, you get E0277 (trait bound not satisfied) when the runtime tries to enforce Send across thread boundaries. The Client is Send, but the underlying channel expects the receiver to be active. The error message points to the channel type. The fix is to ensure the connection task is spawned first.
If you accidentally drop the Connection handle before spawning it, you get E0382 (use of moved value) if you try to use it later, or silent failure if you let it go out of scope. The compiler will warn about unused variables if you bind it with let _connection = .... Do not suppress that warning. The connection task must own the handle.
If you mix the sync postgres crate with tokio-postgres, the compiler rejects you with type mismatch errors. The sync crate uses blocking I/O. The async crate uses non-blocking I/O. They are not compatible. Pick one ecosystem and stick to it.
Counter-intuitive but true: the more you try to hide the connection task, the harder debugging becomes. Leave the spawn call visible. Add a log line when it starts. Add a log line when it fails. Treat the connection task as a first-class component of your architecture.
When to reach for what
Database access in Rust offers several paths. The right choice depends on your runtime, your query patterns, and your team's familiarity with async.
Use tokio-postgres when you are already running a tokio runtime and need raw control over queries, transactions, and connection lifecycle. Use postgres when you are writing a synchronous CLI tool or a legacy service that does not use async runtimes. Use sqlx when you want compile-time query validation, automatic type mapping, and a unified API that works across PostgreSQL, MySQL, and SQLite. Use a connection pool like deadpool-postgres when your application handles concurrent requests and you need to reuse connections instead of opening a new TCP socket for every query. Reach for plain tokio-postgres when you are learning the wire protocol or building a custom abstraction layer.
Trust the borrow checker here. It will force you to structure your database access correctly before you deploy.