boto3 Session vs Client vs Resource: Which to Use, Default Session, Region, Profiles, Credentials, Threads and Config

Logeshwaran
—

In boto3, a session holds your configuration (credentials, Region, profile); a client is the low-level object that maps one-to-one to an AWS API and returns plain dictionaries; a resource is an older, object-oriented layer (bucket.objects.all(), table.put_item()) built on top of a client. You always have a session, even when you do not create one: boto3.client("s3") uses a hidden default session. Use a client for new code: it covers every service and every new feature, and it is thread-safe. Resources exist for only about ten services (S3, EC2, DynamoDB, SQS, SNS, IAM and a few more) and get no new features; they are fine for quick scripts. Create your own session when you need a specific profile, a Region, assumed-role credentials, or one per thread, since sessions and resources are not thread-safe. Below: what each one is, side-by-side code, setting the Region and credentials, setup_default_session, threads and Lambda, Config for retries and timeouts, and the errors you hit along the way.

Jake's script copied the day's reports from the production account into the analytics account. It worked on his laptop. Then he added a second profile for the other account, a thread pool to speed things up, and a run in a different Region, and it started failing in three new ways at once: NoRegionError, credentials from the wrong account, and the occasional strange error from deep inside the SSL library. Every one of them came from mixing up the three objects this page is about.

⚡ Quick Answer

• Session = settings (credentials, Region, profile). Client = API calls. Resource = objects on top. Compare.

• Which to use: client for new code; resource for quick S3 or DynamoDB scripts. Decide.

• A profile or Region: boto3.Session(profile_name="dev", region_name="eu-west-2"). Sessions.

• Threads: share one client; never share sessions or resources. Threads.

What is a client · What is a resource · Region · Credentials · Config · Errors.

New to boto3 errors or credentials? The cards below cover ClientError handling, NoCredentialsError, SSO tokens and paginators.

🧭 NEW HERE? READ THESE FIRST

New to boto3 or AWS credentials? These five pages give you the background this one builds on:

📌 Bookmark this; the comparison table and the thread rules are the parts you will come back for.

Session vs client vs resource, in one table

Here is the difference between the three, side by side. If you only read one section, read this one:

SessionClientResource
What it isYour configuration: credentials, Region, profileLow-level connection to one service; one method per API operationObject-oriented wrapper (buckets, tables, instances) over a client
Create itboto3.Session(...), or the hidden default onesession.client("s3") or boto3.client("s3")session.resource("s3") or boto3.resource("s3")
ReturnsClients and resourcesPlain Python dicts that mirror the API responsePython objects with attributes and actions
ServicesAllEvery AWS serviceAbout ten (S3, EC2, DynamoDB, SQS, SNS, IAM, CloudFormation, CloudWatch, Glacier, OpsWorks)
New featuresn/aYes, as soon as the API has themNo; the resource layer is feature-frozen
Paginationn/aManual, or with paginatorsAutomatic in collections (.all(), .filter())
Thread-safe?NoYes (with caveats)No
Use it forProfiles, Regions, assumed roles, one per threadAll new codeShort scripts on S3 or DynamoDB where readability wins

The relationship is a chain: a session makes clients; a resource holds a client inside it (you can reach it with resource.meta.client). Whichever you call, the same HTTPS request to the same AWS API goes out at the end.

Jake: "So a resource isn't a different API. It's a client wearing a nicer jacket?"

Ethan: "Exactly. A jacket nobody is sewing new pockets onto any more."

What is a boto3 client?

A boto3 client is a low-level object for one AWS service. Every API operation becomes a method with the same name in snake_case: ListObjectsV2 is list_objects_v2(), GetSecretValue is get_secret_value(). You pass the API's parameters as keyword arguments and get back a dictionary shaped exactly like the API response.

import boto3

s3 = boto3.client("s3", region_name="eu-west-2")
response = s3.list_objects_v2(Bucket="shop-reports", Prefix="2026/10/")
for obj in response.get("Contents", []):
    print(obj["Key"], obj["Size"])

secrets = boto3.client("secretsmanager", region_name="eu-west-2")
db_password = secrets.get_secret_value(SecretId="prod/shop/db")["SecretString"]

What a client gives you that resources do not:

  • Every service: Bedrock, Lambda, Secrets Manager, Step Functions, Scheduler, Cost Explorer (get_cost_and_usage), Route 53 (change_resource_record_sets). None of these has a resource.
  • Every parameter of every operation, including ones added last week.
  • Paginators for list calls that return pages: s3.get_paginator("list_objects_v2"). Our guide to listing more than 1,000 S3 objects shows the pattern.
  • Waiters that poll until something is ready: ec2.get_waiter("instance_running").wait(InstanceIds=[instance_id]).
  • Modeled exceptions such as s3.exceptions.NoSuchKey, covered in our boto3 ClientError guide.

The cost is that you work with the raw API shapes. For DynamoDB that means typed values like {"S": "B-1042"} and {"N": "3"} instead of plain strings and numbers; for S3 it means checking IsTruncated or using a paginator yourself.

What is a boto3 resource?

A boto3 resource wraps a client in Python objects. Instead of calling an operation and reading a dictionary, you work with things: a Bucket, an Object, a Table, an Instance. Each has:

  • Identifiers: what makes it unique, such as a bucket name or a queue URL.
  • Attributes: properties loaded lazily from the service, such as obj.last_modified.
  • Actions: methods that call the service, such as queue.receive_messages().
  • Collections: iterables that page through results for you, such as bucket.objects.filter(Prefix="2026/10/").
import boto3

s3 = boto3.resource("s3")
bucket = s3.Bucket("shop-reports")
for obj in bucket.objects.filter(Prefix="2026/10/"):   # pages automatically
    print(obj.key, obj.size)

table = boto3.resource("dynamodb").Table("Bookings")
table.put_item(Item={"id": "B-1042", "nights": 3})     # plain Python values
item = table.get_item(Key={"id": "B-1042"}).get("Item")

That is genuinely nicer to read, and for DynamoDB the Table resource converting Python values for you is the biggest win (our DynamoDB PutItem vs UpdateItem guide uses it). Three things to know before you build on it:

  1. It is feature-frozen. The boto3 team adds no new features to resources. Existing ones keep working, but anything new in a service arrives on the client only.
  2. Few services have one. Check your installed version with boto3.session.Session().get_available_resources(); boto3.resource("lambda") fails with ResourceNotExistsError.
  3. Lazy attributes cost calls. Reading obj.last_modified on an Object you created by name triggers a HEAD request. In a loop over thousands of objects, that is thousands of extra API calls and a slower, pricier script. Objects from a collection already carry the listed fields.

When a resource cannot do something, you do not have to switch everything: s3.meta.client is the client inside it, sharing the same session and connection pool.

Client or resource: which should you use?

The usual question, "boto3 client vs resource", has a short answer: use the client unless you have a reason not to. Decide in this order:

  1. Is the service one of the ten with a resource? If not, the question is settled: client.
  2. Is this code going to live for years (a product, a shared library, a Lambda function)? Client: it will never be stuck waiting for a feature that resources will not get.
  3. Do you need new parameters or operations (S3 conditional writes, new DynamoDB options, anything recent)? Client.
  4. Is it a short script on S3 or DynamoDB where readability matters more than anything? A resource is fine, and the DynamoDB Table saves real effort.
  5. Running work in threads? Client, shared across threads, since resources must be created per thread.

Mixing is normal. Plenty of good code uses boto3.resource("dynamodb").Table(...) for items and a client for everything else in the same file.

What about speed? For the same work, the two send the same requests, so network time is identical; the resource adds a little Python overhead for building objects, which only shows up in very tight loops over huge result sets. The real performance difference comes from lazy attribute loading on resources (extra requests you did not notice) and from creating clients or resources over and over instead of once. Fix those two, and the choice between client and resource becomes purely about readability and features.

Jake: "My script uses the S3 resource for listing and the client for copying. Is that wrong?"

Ethan: "No. Just make both from the same session, so they're the same account and Region."

S3 side by side: the same jobs with a client and a resource

Seeing the two styles do the same work is the quickest way to feel the difference. Each pair below does one everyday S3 job:

import boto3

session = boto3.Session(profile_name="shop-prod", region_name="eu-west-2")
s3c = session.client("s3")
s3r = session.resource("s3")

# upload a file
s3c.upload_file("report.csv", "shop-reports", "2026/10/report.csv")
s3r.Bucket("shop-reports").upload_file("report.csv", "2026/10/report.csv")

# download a file
s3c.download_file("shop-reports", "2026/10/report.csv", "local.csv")
s3r.Object("shop-reports", "2026/10/report.csv").download_file("local.csv")

# delete everything under a prefix
pages = s3c.get_paginator("list_objects_v2").paginate(Bucket="shop-reports", Prefix="tmp/")
for page in pages:
    keys = [{"Key": o["Key"]} for o in page.get("Contents", [])]
    if keys:
        s3c.delete_objects(Bucket="shop-reports", Delete={"Objects": keys})
s3r.Bucket("shop-reports").objects.filter(Prefix="tmp/").delete()

# a presigned download link: client only
url = s3c.generate_presigned_url("get_object",
        Params={"Bucket": "shop-reports", "Key": "2026/10/report.csv"}, ExpiresIn=3600)

Three things stand out. The resource versions are shorter, especially the prefix delete, which pages and batches for you. The client versions show exactly which API calls happen, which matters when you are counting requests on a big bucket. And some jobs, like presigned URLs, exist only on the client, so even resource-heavy code ends up holding a client somewhere (s3r.meta.client.generate_presigned_url(...) works too). Our upload_file vs put_object guide covers the upload choices in more depth.

A word on cost, because it is easy to miss: the resource's convenient prefix delete still sends the same list and batch delete requests the client version does, so the S3 request charges are identical. Where resources can cost more is lazy loading. Reading an attribute such as content_length on an Object you built by name sends a HEAD request each time a fresh object is loaded, so a loop that touches attributes on 100,000 named objects quietly adds 100,000 requests. When you loop over a collection (bucket.objects.filter(...)), the listed fields such as key, size and last modified are already there and cost nothing extra.

What actually happens when you call a client method

Knowing the steps behind s3.list_objects_v2(Bucket="shop-reports") explains almost every error message boto3 can give you:

  1. Validate the parameters against the service model that ships with botocore. A misspelled or unknown parameter fails here, before anything leaves your machine, with ParamValidationError.
  2. Resolve the endpoint from the client's Region (and any custom endpoint URL). No Region means NoRegionError at this step.
  3. Find credentials, lazily, the first time a call needs them, walking the credential chain described below. Nothing found means NoCredentialsError.
  4. Sign the request with Signature Version 4, using the credentials and the current time. A clock that is badly wrong on your machine gives signature or "request time too skewed" errors here.
  5. Send it over HTTPS from the client's connection pool, reusing an open connection where it can.
  6. Retry if needed: throttling and some server errors are retried under the rules of the retry mode, with backoff between attempts.
  7. Parse the response into a dictionary, or turn an error response into a ClientError carrying the AWS error code and message.

Resources go through the same steps; they only add the object layer before step 1 and after step 7. So when something fails, ask which step it failed at: a local validation problem, a configuration problem (Region or credentials), a network or clock problem, or a real answer from AWS. The exception class tells you which, and our ClientError guide covers the last kind.

DynamoDB: the client, the Table resource, and converting types

DynamoDB is where the two styles differ most, because the low-level API wraps every value in a type marker. With the client, you write and read those markers yourself:

ddb = session.client("dynamodb")
ddb.put_item(TableName="Bookings",
             Item={"id": {"S": "B-1042"}, "nights": {"N": "3"}, "paid": {"BOOL": True}})
raw = ddb.get_item(TableName="Bookings", Key={"id": {"S": "B-1042"}}).get("Item")
# raw == {"id": {"S": "B-1042"}, "nights": {"N": "3"}, "paid": {"BOOL": True}}

The Table resource converts for you, and its query helpers build the expression strings:

from boto3.dynamodb.conditions import Key, Attr

table = session.resource("dynamodb").Table("Bookings")
table.put_item(Item={"id": "B-1042", "nights": 3, "paid": True})

resp = table.query(KeyConditionExpression=Key("guest").eq("jake@shop.example"),
                   FilterExpression=Attr("nights").gte(2))
for item in resp["Items"]:
    print(item["id"], item["nights"])    # nights comes back as Decimal("3")

If you prefer the client but hate the markers, boto3 ships the converters the resource uses internally:

from boto3.dynamodb.types import TypeSerializer, TypeDeserializer

to_ddb, from_ddb = TypeSerializer(), TypeDeserializer()
item = {"id": "B-1042", "nights": 3}
ddb.put_item(TableName="Bookings", Item={k: to_ddb.serialize(v) for k, v in item.items()})
plain = {k: from_ddb.deserialize(v) for k, v in raw.items()}

One surprise either way: numbers come back as decimal.Decimal, not int or float, and the standard json module refuses to encode them. Convert on the way out, for example with json.JSONEncoder(default=str).encode(item), or convert to int where you know the field is whole. Floats going in must be passed as Decimal too, or the serializer raises a TypeError about float types.

Updating one field: update_item. Changing one attribute without rewriting the whole item looks like this in both styles; note that the resource still uses expression strings, it only spares you the type markers in the values:

table.update_item(Key={"id": "B-1042"},
                  UpdateExpression="SET nights = :n, paid = :p",
                  ExpressionAttributeValues={":n": 4, ":p": True},
                  ReturnValues="UPDATED_NEW")

ddb.update_item(TableName="Bookings", Key={"id": {"S": "B-1042"}},
                UpdateExpression="SET nights = :n, paid = :p",
                ExpressionAttributeValues={":n": {"N": "4"}, ":p": {"BOOL": True}})

Reading one item back is table.get_item(Key=...) or ddb.get_item(TableName=..., Key=...); in both, check for the Item key, because a missing item returns a response without it rather than an error.

Writing many items: batch_writer. The Table resource has one helper the client lacks: a batch writer that groups your writes into BatchWriteItem calls of up to 25 items and resends any unprocessed items for you:

with table.batch_writer(overwrite_by_pkeys=["id"]) as batch:
    for booking in bookings:            # thousands of dicts
        batch.put_item(Item=booking)
    batch.delete_item(Key={"id": "B-0001"})

With the client you would build the 25-item batches yourself and loop on UnprocessedItems until it is empty; the batch writer does both. overwrite_by_pkeys drops duplicate keys inside one batch, which otherwise fail the whole batch with a validation error.

Reading a whole table: pagination differs. A scan or query returns at most 1 MB per call. The client has a paginator (ddb.get_paginator("scan")) that follows the pages for you. The Table resource does not, so you loop on LastEvaluatedKey yourself:

items, kwargs = [], {}
while True:
    page = table.scan(**kwargs)
    items.extend(page["Items"])
    if "LastEvaluatedKey" not in page:
        break
    kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]

Forgetting that loop is a classic bug: the code works in testing with a small table and silently returns only the first megabyte in production.

Jake: "My API returned an error on every booking with a price."

Ethan: "Decimal from DynamoDB into json. Convert it before you return the response."

What is a boto3 session, and the default session?

A session stores configuration: which credentials, which Region, which profile. When you call boto3.client("s3"), boto3 quietly creates a default session the first time and reuses it, so for a simple script you never see it. You need your own session when you want something the default does not give you:

import boto3

prod = boto3.Session(profile_name="shop-prod", region_name="eu-west-2")
analytics = boto3.Session(profile_name="shop-analytics", region_name="eu-west-2")

src = prod.client("s3")
dst = analytics.client("s3")

print(prod.client("sts").get_caller_identity()["Account"])       # which account am I?
print(prod.region_name, prod.profile_name, prod.available_profiles)

Two sessions, two accounts, no environment variables changing underneath you. That is what fixed Jake's "credentials from the wrong account" problem: his script had relied on AWS_PROFILE, which a teammate's shell set differently.

If you would rather change the default for a whole script, boto3.setup_default_session() replaces the default session, and every later boto3.client() call uses it:

import boto3

boto3.setup_default_session(profile_name="shop-prod", region_name="eu-west-2")
s3 = boto3.client("s3")          # uses shop-prod in eu-west-2

Handy in a notebook or a one-off script. In larger programs, passing an explicit session around is clearer, because it makes the account and Region visible in the code instead of hiding them in global state.

Other useful session methods: get_available_services(), get_available_regions("s3"), and get_credentials() (to see which access key ID was picked up, without printing the secret).

Setting the Region (and NoRegionError)

Every client needs a Region. If boto3 cannot find one, the first call fails with botocore.exceptions.NoRegionError: You must specify a region. Boto3 looks for it in this order, and the first one it finds wins:

  1. region_name= passed to the client: session.client("s3", region_name="eu-west-2").
  2. region_name= in a Config object passed to the client.
  3. region_name= passed to the Session.
  4. The AWS_DEFAULT_REGION environment variable.
  5. The region line of the active profile in ~/.aws/config.

On a laptop, step 5 usually saves you; in Docker, cron or CI, there is no config file and nothing is set, which is why scripts that work locally fail there. In Lambda the Region is already set in the environment for you. A client is tied to one Region for its whole life, so to work in two Regions you create two clients:

session = boto3.Session(profile_name="shop-prod")
s3_london = session.client("s3", region_name="eu-west-2")
s3_sydney = session.client("s3", region_name="ap-southeast-2")

For S3 specifically, a client in the wrong Region often still works (with a redirect), but presigned URLs and some operations break; keep the client's Region the same as the bucket's.

Credentials: profiles, keys and assumed roles

Boto3 searches for credentials in a fixed order and stops at the first source that has them:

  1. Keys passed to boto3.client().
  2. Keys passed to boto3.Session().
  3. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN.
  4. The assume role provider (a profile with role_arn).
  5. Assume role with web identity (AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE, as in EKS).
  6. IAM Identity Center (SSO) profiles.
  7. The shared credentials file, ~/.aws/credentials.
  8. Console login credentials.
  9. The AWS config file, ~/.aws/config.
  10. The old boto2 config files.
  11. The container credential provider (ECS tasks).
  12. The EC2 instance metadata service (an instance with an IAM role).

The order explains most "wrong account" surprises: a stale AWS_ACCESS_KEY_ID in your environment (step 3) beats the profile you thought you were using (steps 4-9). Run sts.get_caller_identity() at the start of any script that touches more than one account.

About "boto3 client with credentials": yes, you can pass aws_access_key_id and aws_secret_access_key directly, but keys written into code end up in Git history, logs and screenshots. A profile on your machine, a role on EC2, ECS or Lambda, or SSO for people is safer, and costs nothing. Real secrets for your app (database passwords, API keys) belong in Secrets Manager, read with the client shown earlier.

To work in another account through a role, the cleanest way is a profile, because boto3 then refreshes the temporary credentials for you:

# ~/.aws/config
[profile shop-analytics]
role_arn = arn:aws:iam::444455556666:role/report-writer
source_profile = shop-prod
region = eu-west-2

If you assume the role in code instead, build a session from the temporary keys. Those credentials expire (one hour by default) and are not refreshed, so long-running jobs should use the profile approach:

sts = boto3.client("sts")
creds = sts.assume_role(RoleArn="arn:aws:iam::444455556666:role/report-writer",
                        RoleSessionName="jake-report-copy")["Credentials"]
analytics = boto3.Session(
    aws_access_key_id=creds["AccessKeyId"],
    aws_secret_access_key=creds["SecretAccessKey"],
    aws_session_token=creds["SessionToken"],
    region_name="eu-west-2",
)

If the assume call itself is refused, our sts:AssumeRole AccessDenied guide covers the trust policy side. Using SSO profiles and seeing token errors? Our SSO token guide has the fix.

Jake: "Why did my script write into the wrong account yesterday?"

Ethan: "Old keys in your environment variables. They beat your profile. Check get_caller_identity before you copy anything."

Sessions with IAM Identity Center (SSO) profiles

For people (as opposed to servers), the safest way to give boto3 credentials is an IAM Identity Center profile: no long-lived keys on the laptop at all. Set it up once with the AWS CLI (aws configure sso), which writes a profile like this to ~/.aws/config:

[profile shop-dev]
sso_session = shop
sso_account_id = 111122223333
sso_role_name = Developer
region = eu-west-2

[sso-session shop]
sso_start_url = https://shop-example.awsapps.com/start
sso_region = eu-west-2
sso_registration_scopes = sso:account:access

Then sign in from a terminal with aws sso login --profile shop-dev, and boto3 picks up the cached token for that profile: boto3.Session(profile_name="shop-dev") just works. Two things to expect. The sign-in lasts for the session duration your administrator set, after which calls fail until you run aws sso login again; and boto3 itself never opens the browser to sign you in, so a script started from cron cannot refresh an expired SSO sign-in on its own. Scheduled jobs belong on a role (EC2, ECS, Lambda) instead. Our SSO token guide covers the "token has expired and refresh failed" error in detail.

Long-running jobs and expiring credentials

Temporary credentials expire, and a job that runs longer than they last fails halfway through. How boto3 handles it depends on where the credentials came from:

  • Roles on EC2, ECS and Lambda: refreshed automatically before they expire. Nothing to do.
  • Profiles with role_arn: boto3 assumes the role again when the credentials near expiry. Nothing to do, which is why this is the recommended way to work across accounts.
  • SSO profiles: role credentials are refreshed from the cached sign-in, until the sign-in itself expires.
  • Keys you passed in yourself (from your own assume_role call): never refreshed. When they expire, every call fails with an expired token error, often an ExpiredToken or ExpiredTokenException code.

If you must assume a role in code for a long job, ask for a longer duration (DurationSeconds, up to the role's maximum session duration), and rebuild the session before the expiry time in the response passes. A simple pattern is to keep the Expiration value, check it before each batch of work, and create a fresh session when less than ten minutes remain. botocore has an internal refreshable-credentials class that does this automatically, but it is not a public API, so a profile with role_arn is the cleaner route whenever the job runs where a config file can exist.

Jake: "The copy job died at minute sixty-one."

Ethan: "One-hour credentials from your own assume_role call. Use the profile with role_arn and boto3 refreshes them for you."

The environment variables boto3 reads

Much of a session's behavior can be set from outside the code, which is useful in containers and CI and confusing when a variable is set without you knowing. The ones that matter most:

VariableWhat it changes
AWS_PROFILEWhich profile the default session uses
AWS_DEFAULT_REGIONThe Region when none is passed in code
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKENCredentials that beat every profile
AWS_CONFIG_FILE, AWS_SHARED_CREDENTIALS_FILEWhere the config and credentials files live
AWS_RETRY_MODE, AWS_MAX_ATTEMPTSRetry mode and total attempts
AWS_ENDPOINT_URL, AWS_ENDPOINT_URL_S3 (and other service names)A custom endpoint for all services or one service, such as a local emulator
AWS_CA_BUNDLEA custom certificate bundle, often needed behind a company proxy

When a script behaves differently on two machines, print the relevant variables first (never the secret key itself). A forgotten AWS_ENDPOINT_URL from a local testing session, pointing every client at an emulator, is a surprisingly common cause of "the bucket does not exist".

A pattern for many accounts and Regions

Jake's copier grew into a nightly job across four accounts and three Regions. The structure that kept it readable: one session per account, created once, and a small cache of clients keyed by account, service and Region.

import boto3

ACCOUNTS = {"prod": "shop-prod", "analytics": "shop-analytics", "dev": "shop-dev"}
REGIONS = ["eu-west-2", "us-east-1", "ap-southeast-2"]

sessions = {name: boto3.Session(profile_name=profile) for name, profile in ACCOUNTS.items()}
_clients = {}

def client(account, service, region):
    key = (account, service, region)
    if key not in _clients:
        _clients[key] = sessions[account].client(service, region_name=region)
    return _clients[key]

for account in ACCOUNTS:
    for region in REGIONS:
        buckets = client(account, "s3", region).list_buckets().get("Buckets", [])
        print(account, region, len(buckets))

Why it works: each session's credentials stay separate, so nothing leaks between accounts; each client is created exactly once, so the job does not pay the client start-up cost hundreds of times; and the account and Region appear in every call, so a reader can see where each request goes. With role-based profiles (role_arn plus source_profile), the sessions also refresh their temporary credentials on their own during a long run.

Creating a client is not free. Each one loads its service model and sets up a connection pool, which takes noticeable time and memory compared with a single API call, so a function that creates a new client on every call can spend more time setting up than working. Cache clients like this, or create them once at module level, and reuse them.

boto3 versions and "Unknown parameter in input"

Every boto3 release ships updated service models, and new API features appear only in releases made after AWS launched them. If you pass a new parameter with an old boto3, step 1 above fails with ParamValidationError: Unknown parameter in input, even though the parameter is documented. Likewise, a brand-new service gives UnknownServiceError.

Check your version with python -c "import boto3; print(boto3.__version__)" in a terminal, and upgrade with pip install --upgrade boto3. Two places hold old versions without telling you:

  • AWS Lambda's built-in boto3 is updated on AWS's schedule, not yours, and can be months behind. For anything recent, include your own boto3 in the deployment package or a layer, pinned to the version you tested.
  • Docker images and CI caches keep whatever version was installed when they were built. Pin boto3 (and botocore, which moves with it) in your requirements file so every environment runs the same version.

Pinning also protects you the other way: an unexpected upgrade can change default behavior, so upgrade on purpose, run your tests, and then move the pin.

Threads, processes and Lambda

This is where Jake's strange SSL errors came from. The rules are short:

  • Clients are thread-safe: create one client and pass it to all your threads.
  • Sessions and resources are not: if each thread needs its own, create a new session (and resource) inside the thread.
  • boto3.client() inside threads is a trap: it uses the shared default session, and calling it from many threads at once can cause odd failures. Create the client before you start the threads.
  • Processes share nothing: a client cannot be passed to another process (multiprocessing). Create clients inside each worker process.
import boto3
from botocore.config import Config
from concurrent.futures import ThreadPoolExecutor

session = boto3.Session(profile_name="shop-prod", region_name="eu-west-2")
s3 = session.client("s3", config=Config(max_pool_connections=32))   # one client, shared

def copy_one(key):
    s3.copy_object(Bucket="shop-analytics-in", Key=key,
                   CopySource={"Bucket": "shop-reports", "Key": key})

keys = [o["Key"] for page in s3.get_paginator("list_objects_v2").paginate(Bucket="shop-reports", Prefix="2026/10/")
        for o in page.get("Contents", [])]
with ThreadPoolExecutor(max_workers=32) as pool:
    list(pool.map(copy_one, keys))

Notice max_pool_connections: a client keeps 10 HTTP connections by default. With more threads than that, you see the warning "Connection pool is full, discarding connection", and the extra threads wait. Set the pool size to at least your thread count.

In AWS Lambda, create clients outside the handler, at the top of the file. The execution environment is reused between invocations, so the client and its open connections are reused too, which saves time on every warm call and lowers your Lambda bill a little:

import boto3

s3 = boto3.client("s3")              # created once per execution environment

def handler(event, context):
    s3.put_object(Bucket="shop-reports", Key="ping.txt", Body=b"ok")
    return {"ok": True}

A few more Lambda habits keep boto3 code reliable there:

  • Credentials and Region are already set. Lambda provides the execution role's temporary credentials and the Region through environment variables, so boto3.client("s3") needs no arguments. If a call is denied, the fix is the execution role's policy, not code.
  • Keep timeouts inside the function's timeout. The default connect and read timeouts are 60 seconds each, with retries on top; a function with a 30-second timeout can be killed mid-retry. Set read_timeout and retries so the worst case finishes before the function's own limit.
  • Cross-account calls: assume the role at module level or cache the session with its expiry time, rather than calling assume_role on every invocation, which adds a round trip and STS calls to every request.
  • Bundle your own boto3 when you need recent features, as covered in the versions section below.

Config: retries, timeouts, connection pool and endpoints

The Config object from botocore is how you tune a client. The settings you will actually use:

from botocore.config import Config

config = Config(
    region_name="eu-west-2",
    retries={"total_max_attempts": 6, "mode": "standard"},
    connect_timeout=5,
    read_timeout=30,
    max_pool_connections=32,
)
s3 = session.client("s3", config=config)

# local testing against LocalStack or a VPC endpoint
dynamodb = session.client("dynamodb", endpoint_url="http://localhost:4566")
Retry modeDefault attempts (including the first)Notes
legacy5The default in boto3 if you set nothing
standard3Consistent retry rules across AWS SDKs, exponential backoff up to 20 seconds; a good choice for new code
adaptive3Standard plus client-side rate limiting; experimental

One trap in the retry settings: max_attempts in a Config counts retries only, while max_attempts in ~/.aws/config or AWS_MAX_ATTEMPTS counts total attempts. Use total_max_attempts in code and you never have to remember which is which. The timeouts default to 60 seconds each; our ReadTimeoutError guide explains when to raise or lower them.

Settings resolve from the most specific source down: a Config passed to the client, then environment variables, then ~/.aws/config. They merge one value at a time, so a Config that sets only the retries still takes the Region from your profile.

See exactly what a client sends

When a call behaves oddly (the wrong endpoint, a retry you did not expect, a parameter that seems ignored), turn on botocore's own logging for a moment:

import logging
import boto3

boto3.set_stream_logger("botocore", logging.DEBUG)
s3 = boto3.client("s3", region_name="eu-west-2")
s3.list_buckets()

The output shows which credential provider was used, the endpoint URL, the request parameters, each retry and the response status. It is the fastest way to answer "which account and Region did this actually hit?" Keep it for local debugging only: debug logs can contain request bodies and other sensitive data, so they do not belong in production logs.

For a lighter touch, every client has an event system. A handler registered on an event runs for every matching call, which is handy for timing or tagging requests without changing the calling code:

def show_call(params, model, **kwargs):
    print("calling", model.name)

s3.meta.events.register("before-call.s3.*", show_call)
s3.list_objects_v2(Bucket="shop-reports", MaxKeys=1)    # prints: calling ListObjectsV2

Events are per client, so registering on one client does not affect others made from the same session.

Testing code that uses boto3: Stubber and moto

Code that calls AWS is hard to test if every test hits a real account. The way you structure sessions and clients decides how easy it is. Pass the client into your function instead of creating it inside, and both of these tools slot in cleanly:

botocore's Stubber queues the exact responses a client should return, and fails the test if the code calls something unexpected:

import boto3
from botocore.stub import Stubber

def count_reports(s3, bucket):
    return s3.list_objects_v2(Bucket=bucket).get("KeyCount", 0)

s3 = boto3.client("s3", region_name="eu-west-2")
with Stubber(s3) as stub:
    stub.add_response("list_objects_v2", {"KeyCount": 2}, {"Bucket": "shop-reports"})
    assert count_reports(s3, "shop-reports") == 2

moto (a third-party library) goes further and fakes whole services in memory, so a test can create a bucket, write objects and read them back with no AWS account at all:

import boto3
from moto import mock_aws

@mock_aws
def test_upload_and_list():
    s3 = boto3.client("s3", region_name="us-east-1")
    s3.create_bucket(Bucket="shop-reports")
    s3.put_object(Bucket="shop-reports", Key="a.csv", Body=b"x")
    assert s3.list_objects_v2(Bucket="shop-reports")["KeyCount"] == 1

Which to pick? Stubber when you want to test how your code reacts to specific responses and errors (including a ClientError with add_client_error); moto when you want realistic behavior across several calls. Either way, create the client inside the mock or pass it in; a module-level client created before the mock starts can talk to the real AWS endpoint. Set fake credentials in your test environment too, so a mistake can never reach a real account.

boto3 and asyncio

boto3 is synchronous: every call blocks until AWS answers. In an asyncio app (FastAPI, aiohttp), a blocking call stalls every other request on that event loop. You have three options:

  • Run calls in threads: await asyncio.to_thread(s3.get_object, Bucket="shop-reports", Key="a.csv"). Simple, uses the thread-safe shared client, and fine for moderate traffic.
  • Use an async wrapper: the third-party aiobotocore and aioboto3 libraries offer async clients with the same method names. They lag slightly behind boto3 releases, so pin versions together.
  • Keep AWS work out of the request path: put slow jobs (big uploads, report generation) on a queue and let a worker process them with plain boto3.

Whichever you choose, the session rules still apply: one session per account, clients created once and reused, never a new client per request.

Errors you meet with sessions and clients

  • NoRegionError: You must specify a region. No Region anywhere in the lookup order; pass region_name or set AWS_DEFAULT_REGION.
  • NoCredentialsError: Unable to locate credentials: nothing in the credential chain. Common in Docker and cron; our NoCredentialsError guide walks through each environment.
  • ProfileNotFound: The config profile (shop-prd) could not be found: a typo in profile_name or AWS_PROFILE, or the profile is in a config file on another machine. boto3.Session().available_profiles lists the ones boto3 can see.
  • ResourceNotExistsError: you asked for a resource the service does not have, such as boto3.resource("lambda"). Use boto3.client("lambda").
  • UnknownServiceError: Unknown service: 'bedrock-agentcore': your installed boto3 is older than the service. Upgrade with pip install --upgrade boto3.
  • AttributeError: 'S3' object has no attribute 'Bucket': you made a client and used resource syntax. boto3.client("s3") has list_objects_v2(); boto3.resource("s3") has Bucket().
  • "Import boto3 could not be resolved" in VS Code: the editor is using a different Python interpreter from the one where boto3 is installed. Pick the right interpreter (or virtual environment) from the status bar, or install boto3 into the one selected. For autocompletion on clients, the third-party boto3-stubs package adds type hints.

Jake: "Three bugs, and all three were me making clients in the wrong place."

Ethan: "One session per account, one client per Region, created before the threads. That's the whole rule."

Frequently asked questions

What is the difference between a boto3 session, client and resource?

A session holds configuration (credentials, Region, profile); a client makes low-level API calls and returns dicts; a resource is an object-oriented layer built on a client.

What is a boto3 client?

A low-level object for one AWS service with one method per API operation, created with boto3.client("service") or session.client("service").

Should I use boto3 client or resource?

Use the client for new code: it covers every service and new features. Resources are fine for short S3 or DynamoDB scripts.

Are boto3 resources deprecated?

Not removed, but feature-frozen: existing resources keep working and get no new features. New features arrive on clients only.

Which services support boto3 resources?

About ten: S3, EC2, DynamoDB, SQS, SNS, IAM, CloudFormation, CloudWatch, Glacier and OpsWorks. Check with Session().get_available_resources().

What is the boto3 default session?

A session boto3 creates automatically the first time you call boto3.client() or boto3.resource(); it uses your default profile and settings.

How do I use boto3 setup_default_session?

Call boto3.setup_default_session(profile_name="dev", region_name="eu-west-2") once; later boto3.client() calls use it.

How do I set the region in boto3?

Pass region_name to the client or the Session, set AWS_DEFAULT_REGION, or add region to your profile in ~/.aws/config.

How do I use a profile in boto3?

boto3.Session(profile_name="name"), then create clients from that session; or set AWS_PROFILE before the script starts.

How do I pass credentials to a boto3 client?

Pass aws_access_key_id, aws_secret_access_key and aws_session_token, but prefer profiles or IAM roles so keys stay out of your code.

Is a boto3 client thread-safe?

Yes, generally: share one client across threads. Sessions and resources are not thread-safe; create those per thread.

Can I share a boto3 client between processes?

No. Create clients inside each process; clients cannot be shared across processes.

How do I get a client from a resource?

Use resource.meta.client, for example boto3.resource("s3").meta.client.

Where should I create boto3 clients in Lambda?

Outside the handler, at module level, so warm invocations reuse the client and its connections.

How do I assume a role with boto3?

Use a profile with role_arn and source_profile (auto-refreshing), or call sts.assume_role and build a Session from the returned temporary credentials.

What does NoRegionError mean in boto3?

No Region was found in the client, Config, Session, AWS_DEFAULT_REGION or your profile; set one of them.

What is botocore Config used for?

Tuning a client: Region, retries, connect and read timeouts, connection pool size, signature version and more.

What is the default retry mode in boto3?

Legacy, with 5 total attempts. Standard mode (3 attempts) is the common choice for new code.

How do I fix "Connection pool is full, discarding connection"?

Raise max_pool_connections in Config to at least the number of threads sharing the client.

How do I generate a presigned URL with a boto3 resource?

Presigned URLs are a client feature; use resource.meta.client.generate_presigned_url or a client from the same session.

Why does DynamoDB return Decimal in boto3?

DynamoDB numbers are converted to decimal.Decimal to keep precision; convert to int or str before encoding JSON.

How do I convert DynamoDB client output to plain Python?

Use boto3.dynamodb.types.TypeDeserializer on each attribute, or use the Table resource, which converts for you.

How do I see what boto3 is sending?

Call boto3.set_stream_logger("botocore", logging.DEBUG) while debugging locally; it logs credentials source, endpoint, parameters and retries.

How do I mock boto3 in unit tests?

Use botocore's Stubber for queued responses on one client, or the moto library's mock_aws to fake whole services in memory.

What is batch_writer in boto3?

A Table resource helper that groups puts and deletes into BatchWriteItem calls of up to 25 items and resends unprocessed items automatically.

How do I scan a whole DynamoDB table with the boto3 resource?

Loop on LastEvaluatedKey, passing it as ExclusiveStartKey to the next scan; the client's scan paginator does the same for you.

How do I check which credentials a boto3 session is using?

Call session.get_credentials().access_key to see the key ID, or sts get_caller_identity to see the account and role.

How do I use an SSO profile with boto3?

Run aws sso login --profile name in a terminal, then create boto3.Session(profile_name="name"); boto3 uses the cached sign-in.

Why do my boto3 credentials expire in long jobs?

Keys from your own assume_role call are never refreshed; use a profile with role_arn, which boto3 refreshes, or rebuild the session before expiry.

What does "Unknown parameter in input" mean in boto3?

Your installed boto3 is older than the API feature you are using; upgrade boto3, and bundle your own version in Lambda.

Is boto3 async?

No. Run calls with asyncio.to_thread, or use the third-party aiobotocore or aioboto3 libraries for async clients.

For readers preparing for an AWS certification

We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.

Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • CloudOps Engineer – Associate (SOA-C03).

Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:

1. A Python app on EC2 must call AWS APIs without stored access keys. What should it use?

An IAM role attached to the instance (instance profile); the SDK picks up its credentials from instance metadata.

2. An application reads AWS_ACCESS_KEY_ID from the environment and also has a shared credentials file. Which wins in the SDK?

The environment variables; they come earlier in the credential provider chain.

3. Where should a Lambda function create its SDK clients for best performance?

Outside the handler, so warm invocations reuse them.

4. How do you confirm which account and identity a script is using?

Call STS GetCallerIdentity.

5. A script must write to a second account. What is the recommended way to get credentials?

Assume an IAM role in that account (sts:AssumeRole) and use the temporary credentials.

6. Which SDK setting controls how many times a throttled call is retried?

The retry configuration (retry mode and maximum attempts).

Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.

πŸ“š ALSO READ

Where to go next, from timeouts to Secrets Manager:

📌 Bookmark this; the comparison table and the thread rules are the parts you will come back for.

Jake's report copier now creates two sessions at the top (one per account), one S3 client for each, shared by 32 threads with a matching connection pool, and checks get_caller_identity before it copies a single file. It runs in under two minutes instead of fifteen, and it has not written into the wrong account since.

The nightly version across all the accounts uses the same building blocks: profiles with role_arn so credentials refresh themselves, a small client cache keyed by account and Region, Stubber tests for the error paths, and a pinned boto3 in its container. None of it is clever. It is just the three objects used for what each one is for.

📌 If you keep one line from this page

A session is your settings, a client is the API, a resource is a feature-frozen convenience on top; make one session per account, one client per Region, create clients before your threads, and use the client for anything new.

region_name or AWS_DEFAULT_REGION · get_caller_identity before writing · total_max_attempts in Config.

Revision note. Written October 19, 2026. Three objects, one HTTPS request at the end of each; the difference is only how much of the API you can reach.

Related