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.

Reading config in the handler defers failure and repeats work Config read inside the handler runs on every event, so a missing variable fails on the first event not at deploy, and the read repeats on every warm invocation. eventeventevent read in handler re-parses per event KeyError on first event not at deploy
A per-handler read re-parses on every event and hides a missing variable until the first invocation.

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.

Non-secret config versus secrets in Lambda Non-secret config like a table name belongs in Lambda environment variables, but secrets like a database password should come from Secrets Manager or Parameter Store, not the visible function configuration. non-secret config Lambda env var (TABLE_NAME) visible config is fine here table names, flags, endpoints secrets Secrets Manager / Parameter Store never a plain Lambda env var passwords, tokens, keys → SecretStr env vars are visible config; secrets need a manager
Keep visible config in Lambda env vars; fetch secrets from a manager and wrap them in SecretStr.

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.

Module-scope config validated once per cold start, reused when warm On a cold start the init phase constructs and validates the settings model, failing fast on bad config; warm invocations reuse the already-validated object with no re-parsing. cold start init phase runs module construct + validate once, at module scope bad config → cold start fails valid → settings object ready warm invocation skips init reuse validated settings no re-parsing
Validate once during the init phase; warm invocations reuse the already-validated object with no re-parsing.

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_cache plus module-level settings gives one validated object per container.
  • SecretStr keeps the password out of any print that CloudWatch would capture.
  • Avoid importing heavy SDK clients at module scope only to read config; construct boto3 clients 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.

Four Lambda configuration gotchas Module-scope construction runs in the longer init phase, warm invocations reuse module globals via lru_cache, SecretStr keeps secrets out of CloudWatch, and heavy SDK clients should be built lazily. Init phaseWarm reuse SecretStrLazy clients longer timeout — surface config errors here module globals + lru_cache = one object per container keeps the password out of CloudWatch logs build heavy boto3 clients lazily to cut cold-start
Validate in the init phase, reuse across warm invocations, mask secrets, and defer heavy client construction.

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 Duration and 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.

Lambda configuration production-parity checklist Construct the settings model at module import, keep only non-secret config in env vars, pull secrets from a manager into SecretStr, add a CI import test, and alarm on init duration and errors. 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 Duration and initialization errors to catch config failures on deploy
Five checks that push a Lambda configuration failure to CI or deploy, never a mid-traffic invocation.

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.