How to make HTTP requests with reqwest

Make HTTP requests in Rust by adding the reqwest crate and calling reqwest::get() with an async runtime.

How to make HTTP requests with reqwest

You're writing a CLI tool that needs to check if a service is up. Or a scraper that pulls JSON from an API. In Python, you import requests and get your data in three lines. In Rust, you reach for reqwest, the de facto HTTP client. But the moment you try to run the code, the compiler stops you. You forgot the async runtime. You ignored the error type. You tried to print a response that hasn't arrived yet. HTTP in Rust requires a bit more setup, but that setup gives you control over memory, concurrency, and safety that blocking libraries can't match.

Async by default

reqwest is asynchronous by default. Think of it like ordering at a busy restaurant. A synchronous request is standing at the counter and staring at the kitchen until your food arrives. You can't do anything else. An asynchronous request is getting a buzzer. You hand in your order, get the buzzer, and go sit down. You can read a menu, chat with friends, or check your phone. When the buzzer goes off, you go get your food.

reqwest gives you the buzzer. It starts the request and hands you a Future. You await that future to get the result, but while you're waiting, your program can handle other work. This is how Rust handles I/O without spawning a thread for every request. The runtime multiplexes thousands of connections on a few threads. You write code that looks sequential, but the runtime weaves the execution together.

Minimal example

Add reqwest to your Cargo.toml. You also need an async runtime. tokio is the standard choice.

[dependencies]
// Disable default features to avoid native-tls/OpenSSL dependency.
// Enable rustls-tls for a pure-Rust TLS implementation.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
// reqwest is async. You need a runtime to drive the futures.
tokio = { version = "1", features = ["full"] }

Convention aside: reqwest enables native-tls by default, which pulls in OpenSSL bindings. That works, but rustls is pure Rust, faster to compile, and easier to cross-compile. The community standard for new projects is rustls-tls. Stick with rustls unless you have a specific reason to use native-tls.

Here is the smallest working program.

use reqwest;

/// Fetches a URL and prints the status and body.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // reqwest::get is a convenience function for simple cases.
    // It returns a Future, so we must await it.
    let response = reqwest::get("https://httpbin.org/get").await?;

    // The response body is also a Future.
    // We await it to read the content into a String.
    let text = response.text().await?;

    println!("Status: {}", response.status());
    println!("Body: {}", text);
    Ok(())
}

What happens under the hood

When you call reqwest::get, nothing happens on the network yet. The function builds a request object and returns a Future. The await keyword is where the work starts. It tells the runtime, "Start this task. If it's not done, pause this function and let other tasks run. Come back when the data arrives."

The runtime polls the future. The future connects to the server, sends the HTTP headers, and waits for the response. Once the response headers arrive, the future resolves to a Response. You still have to await the body. HTTP bodies are streams. response.text() reads the stream until EOF and gives you a String. If the connection drops or the server returns a 4xx/5xx status, the ? operator propagates the error up to main, which prints it and exits.

The runtime manages the concurrency. You write sequential code. The compiler and runtime handle the rest.

Realistic example

In real code, you rarely use reqwest::get. You create a Client. The client holds a connection pool. Reusing the client means you reuse TCP connections and TLS handshakes. That's a massive performance win if you're making multiple requests. You also need to handle JSON. reqwest integrates with serde. The .json() method deserializes the body directly into a struct.

use reqwest;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct GithubUser {
    login: String,
    id: u64,
}

/// Fetches a GitHub user and deserializes the JSON response.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a Client to reuse connections across requests.
    // This avoids the overhead of new TCP/TLS handshakes.
    let client = reqwest::Client::new();

    // Many APIs require a User-Agent header.
    // reqwest does not set one by default.
    let user: GithubUser = client
        .get("https://api.github.com/users/octocat")
        .header("User-Agent", "rust-faq-example")
        // send() returns a Future that resolves to Response.
        .send()
        .await?
        // json() reads the body and deserializes using serde.
        .json()
        .await?;

    println!("User: {:?}", user);
    Ok(())
}

Convention aside: GitHub and many other APIs reject requests without a User-Agent header. reqwest does not set a default. You must set one, or the server returns 403. Always set a descriptive User-Agent in your code.

Reuse the client. Reusing the client reuses connections. That's the single biggest performance win you can get with reqwest.

Sending data

Fetching is half the battle. APIs expect data. reqwest makes sending JSON, forms, and files straightforward. The builder pattern handles content negotiation automatically.

use reqwest;
use serde::Serialize;

#[derive(Serialize)]
struct CreatePost {
    title: String,
    body: String,
}

/// Sends a JSON payload to an API.
async fn send_post(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> {
    let post = CreatePost {
        title: "Hello".into(),
        body: "World".into(),
    };

    // json() serializes the struct and sets Content-Type: application/json.
    // You don't need to set the header manually.
    let response = client
        .post("https://jsonplaceholder.typicode.com/posts")
        .json(&post)
        .send()
        .await?;

    println!("Created: {}", response.status());
    Ok(())
}

Use .json(&data) to send a serializable struct. reqwest converts it to JSON bytes and sets the Content-Type header. Use .form(&data) for form data. It sets application/x-www-form-urlencoded. You never need to set these headers manually. The builder methods do it for you.

Let the builder set the headers. Manual headers are a source of bugs. Trust the builder methods.

Pitfalls and errors

Network calls are unpredictable. Servers drop connections. DNS fails. If you don't set a timeout, your async task hangs forever, holding a slot in the runtime. reqwest doesn't set a default timeout. You must configure it. Use Client::builder().timeout(Duration::from_secs(10)). This kills the request if no data arrives for 10 seconds.

Reading the body with .text() loads the entire response into memory. That's fine for JSON. It's a disaster for a 500MB video file. For large payloads, use .stream() to process chunks as they arrive. This keeps memory usage constant regardless of file size.

Error handling in reqwest splits into two categories. Transport errors (DNS failure, timeout) and HTTP errors (404, 500). reqwest::Error covers transport. HTTP status codes are in the Response. reqwest::get returns Ok(Response) for 404. If you don't check the status, your code proceeds with empty or error data. Use response.error_for_status() to turn non-success codes into errors.

If you forget await, the compiler stops you. You'll get a type mismatch error. reqwest::get returns a Future, not a Response. The compiler rejects this with E0308 (mismatched types). You're trying to assign a Future to a variable expecting a Response. Add await to resolve the future.

Check the status code. reqwest won't save you from a 404. Treat HTTP errors like transport errors using error_for_status().

Decision matrix

Use reqwest when you need an async HTTP client with a high-level API. It handles connection pooling, TLS, and JSON serialization out of the box.

Use ureq when you need a synchronous HTTP client. It blocks the current thread, which is fine for simple scripts or when you can't use an async runtime.

Use hyper when you need to build an HTTP server or require fine-grained control over the HTTP protocol. reqwest is built on hyper, but hyper is lower level and more verbose.

Use reqwest::blocking when you are writing a synchronous CLI tool and don't want to pull in an async runtime. It provides the same API but blocks the thread. It's a wrapper around the async client, so you get the same features without await.

Use surf when you want a web-framework-agnostic async client that works across different runtimes. It's a wrapper around other clients, though reqwest is more feature-complete.

Stick with reqwest for 99% of client work. It's the ecosystem standard.

Where to go next