The messenger and the whiteboard
You are building a service that tracks user sessions. A request comes in. You need to check if a session exists. Hitting the database for every check adds latency. You reach for Redis. It sits in memory, responds in microseconds, and is the standard sidecar for almost every Rust backend.
Now you need to talk to it. You add the redis crate. You write a few lines. The code runs. Then you deploy. Your application crashes after a few hundred requests. You hit Redis's maxclients limit because every request opened a new TCP connection. Or your async runtime blocks because you called a synchronous function. Or you get a cryptic type error when a key is missing.
Connecting to Redis in Rust is straightforward, but the crate has layers. There is the client configuration, the connection lifecycle, the serialization boundary, and the choice between blocking and non-blocking I/O. Get the layering right, and Redis becomes a reliable extension of your application state. Get it wrong, and you leak resources or block your event loop.
Minimal async connection
The redis crate is the standard interface. It supports both synchronous and asynchronous usage. Modern Rust applications almost always run on an async runtime like Tokio. The crate integrates via the tokio-comp feature.
Add the dependency to your Cargo.toml. The tokio-comp feature enables the async API.
[dependencies]
redis = { version = "0.25", features = ["tokio-comp"] }
tokio = { version = "1", features = ["full"] }
Here is the baseline pattern. You create a Client, get an async connection, and execute commands.
use redis::AsyncCommands;
/// Demonstrates the basic async connection flow.
#[tokio::main]
async fn main() -> redis::RedisResult<()> {
// Client::open parses the URL and stores configuration.
// It does not open a network connection yet.
// This is cheap and safe to call repeatedly.
let client = redis::Client::open("redis://127.0.0.1/")?;
// get_async_connection performs the TCP handshake.
// This is the expensive step. It returns a connection
// that implements AsyncCommands.
let mut conn = client.get_async_connection().await?;
// set sends a command to Redis.
// The crate serializes the arguments automatically.
conn.set("greeting", "Hello from Rust").await?;
// get retrieves the value.
// The type annotation <String> tells the crate how to
// deserialize the Redis reply.
let result: String = conn.get("greeting").await?;
println!("Retrieved: {}", result);
Ok(())
}
The Client holds the configuration. The connection holds the socket. Keep this distinction clear. The client is lightweight. The connection is heavy.
Convention aside: The community treats redis::Client as cheap to clone. Cloning a client does not duplicate the network state. It shares the configuration via an internal Arc. Clone the client freely to pass it around your application. Never clone the connection object unless you are certain of the wrapper semantics; cloning a connection often creates a new socket or shares state in ways that surprise you.
The client is the config. The connection is the wire. Clone the config, manage the wire.
How the crate bridges Rust and Redis
Redis speaks a simple protocol. Commands are strings. Arguments are bulk strings. Replies are integers, strings, arrays, or errors. Rust has a strict type system. The redis crate sits between the two and handles the translation.
When you call conn.set("key", "value"), the crate converts the arguments into Redis protocol bytes. It looks at the types. A &str becomes a bulk string. An i64 becomes an integer argument. A Vec<u8> becomes a bulk string of bytes.
When you call conn.get::<String>("key"), the crate reads the reply. If Redis returns a bulk string, the crate decodes it as UTF-8 and returns a String. If Redis returns an integer, the crate fails the conversion.
This translation relies on two traits: ToRedisArgs and FromRedisValue. Primitive types implement these traits. Custom types do not. If you try to pass a struct to set, the compiler rejects you with E0277 (the trait bound ToRedisArgs is not satisfied).
To store complex data, you have two options. Serialize the struct to a JSON string using serde_json, then store the string. Or use the r#type attribute if you are using the redis crate's derive macros to map structs to Redis hashes. The JSON approach is more portable. The hash approach is more efficient for partial updates.
use redis::AsyncCommands;
/// Stores a struct as JSON.
async fn store_user(client: &redis::Client, user: &User) -> redis::RedisResult<()> {
let mut conn = client.get_async_connection().await?;
// Serialize the struct to a JSON string.
// Redis stores bulk strings, not Rust structs.
let json = serde_json::to_string(user)?;
// Store the JSON string under a key.
conn.set("user:42", json).await?;
Ok(())
}
#[derive(serde::Serialize, serde::Deserialize)]
struct User {
name: String,
email: String,
}
Serialization is explicit. The crate does not guess your struct layout. You control the format.
Realistic pattern: handling missing keys
In production, keys disappear. Sessions expire. Cache entries are evicted. The redis crate treats a missing key as an error when you request a concrete type.
If you call conn.get::<String>("missing_key"), the crate returns an error. It does not return None. This catches bugs early. If you expect a string and get nothing, something is wrong.
But sometimes missing is valid. You need to check if a key exists and handle the absence gracefully. Use Option<T> as the return type. The crate interprets a missing key as None and a present key as Some(value).
use redis::AsyncCommands;
/// Fetches a session, returning None if the key is missing.
async fn get_session(client: &redis::Client, user_id: &str) -> redis::RedisResult<Option<String>> {
let mut conn = client.get_async_connection().await?;
// Request Option<String>.
// If the key exists, the crate returns Some(String).
// If the key is missing, the crate returns None.
// This avoids an error on missing keys.
let session = conn.get::<_, Option<String>>(format!("session:{}", user_id)).await?;
Ok(session)
}
This pattern is essential for cache-aside logic. You try the cache. If you get None, you hit the database, populate the cache, and return the result.
Missing keys aren't nulls. They're errors until you ask for an Option.
Pitfalls and compiler errors
The redis crate has a few traps. The compiler catches some. Redis catches others.
Missing trait import. The set and get methods come from the AsyncCommands trait. If you forget to import it, the compiler rejects you with E0599 (no method named set found). This is the most common error for beginners. Always import redis::AsyncCommands for async code or redis::Commands for sync code.
Type mismatch at runtime. The compiler checks that your Rust type implements FromRedisValue. It does not check what Redis actually holds. If Redis holds an integer 123 and you call get::<String>, the crate returns a runtime error. The error message mentions type conversion failure. Always ensure your storage and retrieval types match.
Blocking in async. Calling client.get_connection() inside an async function blocks the thread. If you run this on the Tokio runtime, you freeze the worker thread. The runtime warns you. Use get_async_connection() in async contexts. Use get_connection() only in synchronous code.
Connection exhaustion. Opening a connection per request is a resource leak. Redis has a maxclients limit, often 10000. If your application spawns a connection for every HTTP request, you hit the limit and new connections fail. The error is a Redis protocol error, not a Rust compiler error. Monitor your connection count.
Type safety is a contract. If Redis holds an integer, don't ask for a string.
Choosing your connection strategy
The redis crate offers multiple ways to manage connections. The right choice depends on your concurrency model and throughput requirements.
Use redis::Client with get_async_connection when you are writing a script, a test, or a low-traffic service where connection overhead is negligible. This pattern is simple and sufficient for sequential workloads.
Use redis::aio::MultiplexedConnection (via get_multiplexed_async_connection) when you need high throughput on a single connection. A multiplexed connection allows multiple in-flight requests on one TCP stream. You can pipeline commands without waiting for each response. This reduces latency for bursty workloads.
Use a connection pool like deadpool-redis or bb8 when your application handles concurrent requests. A pool maintains a fixed number of connections and hands them out to tasks. This prevents exhausting Redis's maxclients limit and amortizes the cost of TCP handshakes. The redis crate includes a basic ConnectionManager, but third-party pools offer better health checks and configuration.
Use get_connection (blocking) only when you are in a synchronous context without an async runtime. This applies to CLI tools, legacy codebases, or environments where async is unavailable. Never call blocking functions from async code.
Pool your connections. Redis will thank you, and so will your uptime.