When the CLI works but your code hangs
You have a Rust binary that needs to list objects in an S3 bucket. The AWS CLI works perfectly. Your credentials are in ~/.aws/credentials. You add aws-sdk-s3 to Cargo.toml, write a function to call list_objects, and run the program. The output is silence. The process exits immediately. Or the compiler rejects your code because you tried to use .await inside a synchronous function.
The AWS SDK for Rust does not hide async execution behind a synchronous wrapper. Every service call returns a future. Every configuration load is asynchronous. The SDK expects you to run your code inside an async runtime and to handle configuration explicitly. This design keeps the SDK fast and memory-efficient, but it forces you to structure your application around async execution from the start.
The two-layer architecture
The SDK splits work into two distinct layers. The aws-config crate handles bootstrapping. It finds credentials, detects the region, sets timeouts, and configures retry behavior. It produces an SdkConfig object. Service crates like aws-sdk-s3 or aws-sdk-dynamodb take that config and give you a client.
Think of SdkConfig as a master key ring. It holds the access credentials and the map of the AWS environment. You don't pass raw secrets to every client. You pass the key ring. The S3 client uses the key ring to open the S3 door. The DynamoDB client uses the same key ring to open the DynamoDB door. The key ring is cheap to clone. It holds references to credential providers and region providers, not the secrets themselves. This allows you to load configuration once and share it across multiple service clients.
The service client holds the HTTP connection pool and the retry logic. When you call a method like list_buckets(), the client builds the request and returns a future. You must .await that future inside an async runtime. The runtime polls the future, sends the HTTP request, waits for the response, deserializes the JSON, and hands you a typed Rust struct.
Minimal setup
Add the crates to your Cargo.toml. You need aws-config for bootstrapping, the service crate for the API, and an async runtime like tokio.
[dependencies]
aws-config = "1.5"
aws-sdk-s3 = "1.5"
tokio = { version = "1", features = ["full"] }
The tokio runtime is the standard choice for Rust applications. The SDK is runtime-agnostic, but you need something to poll the futures. The full feature enables the multi-threaded scheduler, which is what you want for I/O-bound work like HTTP requests.
use aws_config::load_defaults;
use aws_sdk_s3::Client;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// load_defaults returns a future. It checks env vars, shared config, and IAM roles.
// BehaviorVersion::latest() ensures you get the current feature set and defaults.
let config = load_defaults(aws_config::BehaviorVersion::latest()).await;
// Client::from_conf consumes the config. The client holds the HTTP connection pool.
// Cloning the client is cheap; it shares the pool.
let client = Client::from_conf(config);
// send() builds the request and returns a future.
// .await pauses this function until the HTTP response arrives.
// The ? operator propagates errors up to the caller.
let output = client.list_buckets().send().await?;
// The response wraps the bucket list in an Option.
// Check for None before iterating to avoid panics.
if let Some(buckets) = output.buckets() {
for bucket in buckets {
if let Some(name) = bucket.name() {
println!("Bucket: {}", name);
}
}
}
Ok(())
}
The community convention is to use load_defaults for 99% of applications. It handles the complex chain of credential resolution automatically. Reaching for Config::builder is only necessary when you need to override specific settings like the region or endpoint.
What happens under the hood
When load_defaults runs, it performs an async chain. It checks environment variables first. If AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set, it uses them. If those are missing, it reads the shared credentials file at ~/.aws/credentials. If you are running on an EC2 instance or ECS task, it might make an HTTP request to the Instance Metadata Service or the ECS container metadata endpoint to fetch temporary credentials.
This is why load_defaults returns a future. It may need to make network calls to resolve credentials. You must .await it. The resulting SdkConfig is ready to use. It caches the resolved credentials and region.
The Client struct holds a connection pool. When you call send(), the client checks the pool for an available connection. If none is available, it waits or creates a new one. The request is signed using the credentials from the config. The signature includes the region, timestamp, and payload hash. This prevents tampering and ensures the request is authenticated.
The response is deserialized into a typed struct. The SDK generates these structs from the AWS service models. They match the API documentation exactly. If the API returns a field, the struct has a method to access it. If the field is optional, the method returns an Option. This eliminates the guesswork of parsing JSON manually.
Trust load_defaults. It does the heavy lifting of credential resolution and region detection. You rarely need to replicate this logic yourself.
Realistic example: uploading with streaming
S3 objects can be terabytes in size. Loading an entire file into memory before uploading is impractical. The SDK uses ByteStream for request bodies. ByteStream supports streaming data from a file, a buffer, or a custom source without loading everything into RAM.
use aws_config::{BehaviorVersion, Region};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::put_object::PutObjectError;
/// Uploads a file to S3 using streaming to avoid memory pressure.
async fn upload_file(
client: &Client,
bucket: &str,
key: &str,
path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// ByteStream::from_path streams the file directly from disk.
// This keeps memory usage constant regardless of file size.
let body = aws_sdk_s3::primitives::ByteStream::from_path(path).await?;
// Build the request. The builder pattern lets you add metadata or headers.
let result = client
.put_object()
.bucket(bucket)
.key(key)
.body(body)
.send()
.await;
// Match on the Result to handle specific errors.
match result {
Ok(_) => println!("Upload successful"),
Err(err) => {
// SdkError wraps the service error.
// as_service_error() returns Some if the error came from AWS.
if let Some(service_err) = err.as_service_error() {
eprintln!("Service error: {} - {:?}", service_err.code(), service_err.message());
} else {
// Client errors include network timeouts or credential issues.
eprintln!("Client error: {}", err);
}
}
}
Ok(())
}
The convention for ByteStream is to use from_path for files and from_static for small in-memory buffers. Avoid converting a file to a Vec<u8> and then wrapping it. That defeats the purpose of streaming.
Error handling requires inspecting the SdkError. The SDK returns Result<T, SdkError<T>>. SdkError contains both service errors and client errors. Service errors come from AWS, like NoSuchBucket or AccessDenied. Client errors come from the SDK, like network timeouts or credential resolution failures. Use as_service_error() to check for AWS-specific failures. If you ignore the error with let _ = result;, you are hiding failures. The community convention is to use ? for propagation or match explicitly for logging.
Pitfalls and compiler errors
If you write a synchronous main function and try to use .await, the compiler rejects you with E0752 (async fn main is not allowed). You must use #[tokio::main] or wrap your code in a tokio::runtime::Runtime::new()?.block_on(...). Forgetting the runtime attribute causes your program to compile but panic at runtime or hang forever.
Credential issues are the most common runtime failure. If load_defaults cannot find credentials, the SDK returns an error when you try to send a request. The error is an SdkError with a CredentialsError inside. Check your environment variables and shared config file. If you are using IAM roles, ensure the role has the necessary permissions.
Region mismatches cause confusing errors. If you don't configure a region, load_defaults tries to detect it. If detection fails, the SDK might default to us-east-1. This can cause NoSuchBucket errors if your bucket is in a different region and you are making region-sensitive calls. Explicitly set the region in production using Config::builder().region(Region::new("us-west-2")).
The SDK handles retries automatically. It retries on transient failures like network timeouts or 5xx errors. The default retry strategy is adaptive. You can customize it using Config::builder().retry_config(...). Don't implement your own retry loop. The SDK's retry logic is robust and respects AWS rate limits.
Inspect the error type. Don't just print the whole error. Use as_service_error() to distinguish between AWS API failures and client issues. This makes debugging faster and your logs more useful.
When to use what
Use load_defaults when you want the SDK to handle credential loading automatically from environment variables, shared config files, and IAM roles. Use Config::builder when you need to override the default region, set a custom endpoint for localstack, or configure specific timeouts. Use ByteStream::from_path when uploading large files to avoid loading the entire file into memory. Use ByteStream::from_static when sending small, in-memory buffers. Use SdkError::as_service_error when you need to distinguish between AWS API failures and network issues. Use #[tokio::main] when your binary needs to run async code from the entry point. Use Client::from_conf when creating a service client from a shared configuration. Use aws-sdk-s3 when you need S3 operations and aws-sdk-dynamodb when you need DynamoDB operations. Reach for load_defaults first; it covers the standard use case. Reach for Config::builder only when you have a specific constraint that load_defaults cannot satisfy.
Pick the tool that matches your constraint. The SDK gives you the primitives. You assemble them into your application.