Configuration for AWS Lambda Python Handlers
A Lambda handler has an unusual lifecycle: module-level code runs once per cold start and is reused across warm invocations. Reading configuration in the wrong place either re-parses it on every request or defers a fatal error until the first real event. This page shows where to read and validate config, building on the os.environ typing rules and pydantic-settings validation.
Understanding the lifecycle is the whole key. When Lambda cold-starts a function, it runs your module’s top-level code once — the init phase — then calls your handler for each event. If the container stays warm, subsequent events reuse the same loaded module and skip the init phase entirely, calling straight into the handler. This means code at module scope runs once per cold start and its results persist across warm invocations, while code inside the handler runs on every single event. Configuration belongs firmly in the first category: you want to read and validate it once, at import, so a bad value fails the cold start and a good value is reused for the life of the container.
Two things follow from that. First, validating configuration at module scope means a misconfiguration fails during the init phase — which has its own generous timeout and shows up as an initialization error on deploy — rather than surfacing as a handler error on the first real event, potentially minutes or hours after the deploy. Second, secrets deserve special care in Lambda: the environment variables you set on a function are visible in the console and stored at rest, so they are fine for non-secret config but the wrong place for credentials, which should come from a secret manager. The rest of this page places configuration at module scope and sources secrets correctly, and the pattern generalises beyond Lambda to any serverless or short-lived-container runtime with an init-then-invoke lifecycle.
Problem 1: reading config inside the handler
# handler.py — ANTI-PATTERN
import os
def handler(event, context):
table = os.environ["TABLE_NAME"] # re-read every invocation; KeyError mid-request
...
A missing TABLE_NAME here fails on the first event, not at deploy time, and the read repeats on every warm invocation. Both problems stem from reading config inside the handler. Because the handler runs per event, a missing or malformed variable is not discovered until an event actually arrives — which could be well after the deploy that broke it, when the connection between the failure and the change is no longer obvious. And because the read happens every invocation, warm containers re-parse the same value repeatedly, wasting a little work on every request and, for anything more expensive than a dictionary lookup (a secret fetch, a JSON parse), a lot. Moving the read to module scope fixes both problems at once: it runs a single time at cold start, fails there immediately if the config is bad, and the validated result is reused by every warm invocation for free with no further parsing. The handler shrinks to reading an attribute off an already-built object, which is as cheap as configuration access can be.
Problem 2: putting secrets in Lambda environment variables
# ANTI-PATTERN: DB_PASSWORD set as a plain Lambda env var
db_password = os.environ["DB_PASSWORD"] # visible in the console, stored at rest
Lambda environment variables are readable by anyone with configuration access. Secrets belong in AWS Secrets Manager or Parameter Store. A Lambda function’s environment variables are part of its configuration, visible to anyone with lambda:GetFunctionConfiguration — which is a broad and commonly-granted permission — and shown in plaintext in the AWS console to anyone who can view the function. They are also stored at rest as part of the function definition (encrypted with a default key unless you configure a customer-managed one, but readable by the console regardless). That makes them a fine place for a table name or a feature flag, and a poor place for a database password or an API token. The correct pattern is to keep non-secret config in the function’s environment variables and fetch actual secrets from Secrets Manager or Parameter Store at cold start, wrapping them in SecretStr once read. This also composes with the module-scope discipline: the secret fetch happens once during the init phase alongside config validation, so a failure to reach the secret manager fails the cold start cleanly rather than the first handler invocation, and the fetched secret is reused across warm invocations just like the rest of the settings.
Secure implementation
Validate configuration at module scope so a cold start fails fast, and fetch secrets from a manager.
# config.py — imported once per cold start
from functools import lru_cache
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="forbid")
table_name: str
db_password: SecretStr # sourced from Secrets Manager env-injection or SDK
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings() # constructed at import; a bad value fails the cold start
# handler.py
from config import settings
def handler(event, context):
# settings is already validated; no per-invocation parsing
return {"table": settings.table_name}
The structure puts the validated settings object at module scope in config.py, so settings = get_settings() runs during the init phase of a cold start. If a variable is missing or malformed, the model raises there, the cold start fails with an initialization error, and the deploy surfaces the problem immediately. If everything is valid, settings is a fully-validated object that the handler imports and uses with no per-invocation parsing at all — every warm invocation just reads settings.table_name from an already-built object. The lru_cache on get_settings() ensures the model is built exactly once even if get_settings() is called from multiple places, and because the module globals persist across warm invocations, that single validated object is reused for the life of the container across potentially thousands of warm invocations.
Gotchas & version-specific behaviour
- Module-scope construction runs during the init phase, which has a longer timeout than the handler — surface config errors there.
- Warm invocations reuse the module globals, so
lru_cacheplus module-levelsettingsgives one validated object per container. SecretStrkeeps the password out of anyprintthat CloudWatch would capture.- Avoid importing heavy SDK clients at module scope only to read config; construct
boto3clients lazily if cold-start latency matters.
The init-phase timeout is a detail worth exploiting. Lambda gives the init phase a longer, more generous timeout than a handler invocation, so doing config validation (and even a one-time secret fetch) at module scope has room to complete, and its failures show up as clean initialization errors on deploy rather than handler timeouts under load. The flip side is the cold-start-latency caveat: anything expensive you put at module scope adds to every cold start’s duration, so if you import a heavy SDK purely to fetch config, construct those clients lazily (on first use inside the handler) rather than eagerly at import. The balance is to validate config eagerly (cheap, and you want it to fail fast) while deferring genuinely heavy initialization to when it is first needed. A common refinement is to fetch secrets once at module scope too — the init phase’s longer timeout accommodates a single Secrets Manager call — while lazily constructing the database or HTTP clients that use those secrets inside the handler on first use. That way the config-validation failure and the secret fetch both happen at cold start where they belong, but the expensive connection setup does not inflate every cold start’s latency.
Production parity checklist
- Construct the settings model at module import, not inside the handler.
- Keep only non-secret config in Lambda environment variables.
- Pull secrets from a manager and wrap them in
SecretStr. - Add a CI test that imports the handler module with a staging-shaped environment.
- Alarm on
Init Durationand initialization errors to catch config failures on deploy.
The CI-test and alarm items are what make the fail-fast behaviour observable. A CI step that simply imports the handler module against a staging-shaped environment triggers the same module-scope construction Lambda would run, so a missing or malformed variable fails the build rather than the deploy — the cheapest possible place to catch it. And a CloudWatch alarm on Init Duration and initialization errors catches config failures at deploy time in production too: because bad config now fails during the init phase, an initialization-error alarm fires immediately on a broken deploy rather than waiting silently for a user to hit the first bad invocation. Together they push the discovery of a configuration problem as early as possible — CI first, deploy second, and never a mid-traffic surprise for a real user.
Frequently asked questions
Where should a Lambda handler read its configuration?
At module scope, outside the handler function, so it is validated once per cold start and reused across warm invocations. Reading inside the handler re-parses on every request and hides configuration errors until traffic arrives. The reason is Lambda’s execution model: module-level code runs once during the init phase of a cold start and its results persist while the container stays warm, whereas handler code runs on every event. Configuration is a read-once concern, so it belongs where read-once code lives — module scope — where a bad value fails the cold start and a good value is reused across every subsequent warm invocation for free, with the handler doing no configuration work at all.
Are Lambda environment variables a safe place for secrets?
They are visible in the console and to anyone with lambda:GetFunctionConfiguration, and are stored at rest. Use them for non-secret config; pull real secrets from AWS Secrets Manager or Parameter Store at cold start instead. The permission that reveals them is broad and commonly granted, so a Lambda env var is effectively a plaintext value anyone who can inspect the function can read. Keep table names, endpoints, and feature flags there, but source database passwords, API tokens, and signing keys from a manager and wrap them in SecretStr so they are masked in any log the handler might emit.
How do I make a Lambda fail fast on bad configuration?
Construct a pydantic BaseSettings model at module import. If a variable is missing or malformed, the import raises and the Lambda reports an initialization error immediately. Because that construction runs during the init phase of a cold start — which has a longer timeout than a handler invocation — the failure surfaces cleanly on deploy rather than as a handler error on the first event. Pair it with a CI step that imports the same module against a staging-shaped environment, so the identical validation runs before deploy and a bad configuration fails the build first of all.
Key takeaways
Validate Lambda configuration once at module scope so a cold start fails fast, keep secrets in a manager rather than environment variables, and reuse the validated object across warm invocations. The Lambda lifecycle is what makes where you read config matter more than in a long-running service: module-scope code runs once per cold start and persists across warm invocations, while handler code runs on every event. Putting configuration at module scope means it is validated once, fails the cold start if wrong, and is reused for free thereafter — exactly the behaviour you want, and the opposite of reading it inside the handler where it re-parses per event and hides failures until traffic arrives.
The secret-handling difference from a normal service is that Lambda’s environment variables are visible function configuration, so they suit non-secret values but not credentials. Fetch secrets from Secrets Manager or Parameter Store at cold start and wrap them in SecretStr, keeping them out of the visible config and out of CloudWatch logs. Back the whole thing with a CI test that imports the handler module against a staging-shaped environment — reproducing the module-scope construction — and an alarm on initialization errors, so a configuration failure is caught in CI or at deploy rather than on a user’s first invocation. The result is a Lambda whose configuration is validated exactly once, at the earliest possible moment, and never surprises you under load.