AWS Structured Logging: JSON Logs for Lambda & CloudWatch
Structured logging means writing each log event as a JSON object with named fields instead of free-form text. In AWS, that single change unlocks the full power of CloudWatch Logs Insights: field-level filtering, latency aggregation, cross-service correlation, and log-based alarms, all without regex and without scanning every character of every log line. If you have ever spent 20 minutes searching through logs during an incident, structured logging is what turns that into a five-second query.
Structured logging, simply explained
A log is a record of something that happened in your application. Most developers start by writing log lines like sentences: a timestamp, a severity level, and a message in plain English. That works on your laptop.
Structured logging is the same idea, but the record is a JSON object instead of a sentence. Every piece of data has a name. Every value is in a predictable place. The log is still readable by humans but is also instantly queryable by machines.
Here is the same event written both ways:
Plain-text log:
2026-05-03 14:32:01 ERROR Failed to process order 789 for user 456: payment declined (code: CARD_DECLINED)JSON log:
{
"timestamp": "2026-05-03T14:32:01Z",
"level": "ERROR",
"message": "Order processing failed",
"orderId": "789",
"userId": "456",
"errorCode": "CARD_DECLINED",
"durationMs": 243
}Both records contain the same information. But in the JSON version, every piece of data
is in a named field. CloudWatch Logs Insights discovers those fields automatically.
Instead of writing filter @message like /CARD_DECLINED/, you write
filter errorCode = “CARD_DECLINED”. If the message wording ever changes,
the query still works because the field name stays the same.
Mental model
Think of a plain-text log like a sticky note someone left on a whiteboard: readable, but you have to read every single note to find the one about a specific order. A structured log is like a spreadsheet row: every column has a name, you can sort by error code, filter by user ID, and sum latency in seconds. Same information — completely different ability to work with it at scale.
Why plain-text logs break in production
Plain-text logs have four failure modes at scale:
- Searchability. Finding all errors of a specific type means writing a regex. Regex queries are brittle; one wording change in a log message silently breaks the query.
- Grouping and aggregation. To count errors by error code, you first have to extract the error code from a string. That requires a parse step before every query.
- Cross-service correlation. To trace one request across Lambda, SQS, and EventBridge, all services need to include a shared ID in a predictable position. In plain text, that requires strict formatting discipline enforced across every team.
- Query cost. CloudWatch Logs Insights charges per GB scanned. A regex or keyword search scans every character of every log line. A field-level filter on JSON only examines the fields you reference.
These problems are manageable with one service and light traffic. They become painful when you have multiple Lambda functions, an API Gateway, SQS queues, and EventBridge rules all writing logs at the same time.
The hidden cost of text logs. Teams often discover the searchability problem during an incident at 2 AM, not during normal development. By the time it hurts, you have months of unstructured logs you cannot efficiently query and no easy way to fix them retroactively.
How structured logging works in AWS
The flow from application to query is straightforward:
- Your application emits one JSON object per log event to stdout or via the AWS SDK.
- Logs land in a CloudWatch Logs log group (for example,
/aws/lambda/order-service). - CloudWatch Logs Insights scans incoming JSON events and discovers fields automatically. You can see discovered fields in the query console without writing any extra configuration.
- Teams write queries using those field names to filter errors, aggregate latency, and correlate requests across services.
- Metric filters watch for specific field values, like
level = “ERROR”, and publish counts to CloudWatch Metrics. - Alarms fire when those metric counts exceed thresholds.
CloudWatch Logs Insights also supports dot notation for nested JSON fields. If your log
contains {“request”: {“method”: “POST”, “path”: “/orders”}}, you can query
request.method and request.path directly. Keep nesting shallow;
deeply nested structures are harder to query and can make field discovery less reliable.
Field discovery is automatic. You do not need to pre-register field names or configure a schema in CloudWatch. As long as your logs are valid JSON, Logs Insights will discover and index the top fields in each log group automatically.
When to use structured logging
Structured logging pays off whenever you need to search, group, or alert on log data. Concrete use cases:
- Debugging failed requests. Filter by
requestIdorcorrelationIdto see every event tied to one failed call. This is the foundation of debugging production systems. - Cross-service tracing. Pass a correlation ID from API Gateway through Lambda, SQS, and EventBridge. Query all services by that ID in a single Logs Insights query.
- Error breakdowns. Group errors by
errorCodeto see which failure modes dominate. - Latency analysis. Aggregate
durationMsby endpoint or function version to detect regressions. - Log-based metrics and alarms. Create metric filters on fields like
errorCode = “PAYMENT_FAILED”, then trigger alarms when they spike. - Incident response. During an incident, structured logs let you filter to the relevant service, time window, and error category in seconds. See the guide on incident response with monitoring.
For a truly one-off script that runs once, writes a few lines, and never needs to be queried again, plain text is fine. But if the script runs on a schedule, gets debugged by others, or might scale, structuring from the start is easier than retrofitting later.
Structured logging vs unstructured logs
| Structured (JSON) | Unstructured (plain text) | |
|---|---|---|
| Searchability | Filter by exact field value | Regex pattern matching only |
| Aggregation | stats avg(durationMs) by endpoint | Extract field from string first, then aggregate |
| Alerting | Metric filter on named field | Metric filter on text pattern; breaks on wording changes |
| Correlation IDs | filter correlationId = "abc-123" | Text search, fragile if message format changes |
| Onboarding speed | Any field is immediately queryable | Must know the exact message format first |
| Schema stability | Add new fields without breaking queries | Any message change can break existing queries |
Recommended JSON log schema
A shared schema across all your services is more valuable than a perfect schema for one
service. Agree on field names before you start. You can add fields later, but renaming
userId to user_id mid-project means updating every Logs
Insights query and every metric filter that references it.
| Field | Why it matters | Example | Required? |
|---|---|---|---|
timestamp | When the event occurred | 2026-05-03T14:32:01.456Z | Required |
level | Severity for filtering and metric filters | INFO, ERROR, WARN | Required |
message | Static description of the event type | "Order processing failed" | Required |
service | Which service emitted the log | "order-service" | Required |
environment | Prod vs staging for filtering | "production" | Required |
requestId | Ties logs to one Lambda invocation | "abc-123-def-456" | Required |
correlationId | Traces a request across services | "req-789-xyz" | Recommended |
traceId | Links to X-Ray trace | "1-5e3d2c1b-abc" | Optional |
userId | User who triggered the event | "user-456" | When applicable |
orderId | Business entity being processed | "order-789" | When applicable |
errorCode | Machine-readable error category | "PAYMENT_FAILED" | On errors |
durationMs | Execution time for latency analysis | 243 | On timed operations |
Keep message static. The message field should describe
the event type, not the specific instance. Write “Order processing failed”,
not “Order 789 failed for user 456”. Put variable data in named fields.
This is what makes grouping and aggregating logs by event type work reliably.
Implementation options in AWS
You have three realistic paths depending on how much control and automation you need.
Option 1: Native Lambda JSON logging
Lambda supports a built-in JSON log format you can enable in the function’s logging
configuration (in the console or via the CLI with
—logging-config LogFormat=JSON). When enabled, Lambda emits platform
events (START, END, REPORT) as structured JSON and wraps each application log line in
a JSON envelope with timestamp, requestId, level,
and message fields.
In Python, the message field contains whatever string your
print() or logger.info() call emits. To get queryable custom
fields, print a JSON string explicitly:
import json
print(json.dumps({
"message": "Order processing failed",
"orderId": order_id,
"errorCode": "PAYMENT_FAILED",
"durationMs": duration_ms,
}))This is the right choice for simple Lambda functions where you want structured output without adding a dependency. The tradeoff: you get no automatic Lambda context fields (function name, cold start, memory size) unless you add them manually to each event.
Option 2: AWS Lambda Powertools (recommended for Lambda)
For Lambda functions that need richer
context, team-wide conventions, and automatic correlation ID propagation,
Lambda Powertools is the recommended path. The Logger utility
automatically adds Lambda execution context to every log event: function name, version,
ARN, memory size, request ID, and a cold_start boolean. See the
monitoring Lambda
guide for more Lambda-specific observability patterns.
Option 3: Containers, EC2, and other services
The core idea is language-agnostic: emit one JSON object per log event to stdout. The CloudWatch agent or a container log driver (awslogs or Fluent Bit) picks it up and ships it to CloudWatch Logs, where Logs Insights discovers the fields automatically. Node.js, Go, Java, and Ruby all have JSON logging libraries that follow this pattern. The schema table above applies equally to all of them.
Python example with Lambda Powertools
pip install aws-lambda-powertoolsfrom aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.typing import LambdaContext
logger = Logger(service="order-service")
@logger.inject_lambda_context(log_event=True)
def handler(event: dict, context: LambdaContext) -> dict:
order_id = event["orderId"]
user_id = event["userId"]
logger.info("Processing order", extra={"orderId": order_id, "userId": user_id})
try:
result = process_payment(order_id)
logger.info(
"Payment succeeded",
extra={"orderId": order_id, "amount": result["amount"]}
)
return {"statusCode": 200, "body": "ok"}
except PaymentError as e:
logger.error(
"Payment failed",
extra={"orderId": order_id, "errorCode": e.code, "errorMessage": str(e)}
)
return {"statusCode": 500, "body": "payment failed"}Every event Powertools emits includes Lambda context automatically:
{
"level": "INFO",
"message": "Payment succeeded",
"timestamp": "2026-05-03T14:32:01.456Z",
"service": "order-service",
"cold_start": false,
"function_name": "process-orders",
"function_memory_size": 256,
"function_request_id": "abc-123-def-456",
"orderId": "order-789",
"amount": 49.99
}The cold_start field is especially useful for
Lambda performance monitoring.
You can query cold start frequency directly in Logs Insights without any extra
instrumentation.
CloudWatch Logs Insights queries that become easier
Once your logs are structured, these queries are straightforward. Each would require fragile regex or manual parsing with plain-text logs.
Error breakdown by error code:
fields @timestamp, errorCode, orderId
| filter level = "ERROR"
| stats count(*) as errorCount by errorCode
| sort errorCount descP95 and P99 latency over time:
fields @timestamp, durationMs
| filter ispresent(durationMs)
| stats pct(durationMs, 95) as p95, pct(durationMs, 99) as p99, avg(durationMs) as avg
by bin(5m)Full event history for one request:
fields @timestamp, level, message, service
| filter correlationId = "req-789-xyz"
| sort @timestamp ascCold start rate over time (Lambda Powertools):
fields @timestamp, cold_start
| filter ispresent(cold_start)
| stats sum(cold_start) as coldStarts, count(*) as totalInvocations
by bin(1h)Top users by error count:
fields @timestamp, userId, errorCode
| filter level = "ERROR"
| stats count(*) as errors by userId
| sort errors desc
| limit 20All of these use exact field names rather than text patterns: faster to write, more reliable when message wording changes, and cheaper to run because Logs Insights only scans the fields you reference.
Correlation IDs: tracing requests across services
A correlation ID is a unique string you generate at the entry point of a request (at API Gateway, for example) and pass to every downstream service: Lambda, SQS, EventBridge, and any Lambda those services invoke. Every service logs it on every event. When something goes wrong, search for that one ID in Logs Insights and get the complete timeline of the request across all services.
Mental model
A correlation ID works like a package tracking number. When you order something online, one tracking number follows the package from warehouse to delivery truck to your door. A correlation ID does the same for a request: one ID follows it from API Gateway into Lambda, onto SQS, into the next Lambda, and back out again. You can replay the entire journey by searching for that one number.
Without a correlation ID, reassembling what happened means matching timestamps across services and hoping no other requests were in-flight at the same time.
import uuid
from aws_lambda_powertools import Logger
logger = Logger(service="order-service")
def handler(event: dict, context) -> dict:
correlation_id = (
event.get("headers", {}).get("X-Correlation-ID")
or event.get("correlationId")
or str(uuid.uuid4())
)
# All subsequent log calls in this invocation include correlationId
logger.append_keys(correlationId=correlation_id)
logger.info("Handler invoked")
return {"correlationId": correlation_id, "status": "ok"}X-Ray trace IDs as correlation IDs. If you use AWS X-Ray, the trace ID is a natural correlation ID that also links to your distributed traces. Lambda Powertools Logger can inject the X-Ray trace ID into every log event automatically when tracing is enabled on the function.
Security and data-handling rules
Structured logs are expressive, which also makes it easy to accidentally log things you should not. A few rules worth enforcing from the start:
- Never log secrets. API keys, passwords, session tokens, and OAuth credentials should never appear in log events. Structured logging makes it tempting to log full event payloads; always check what is in the object before logging it.
- Avoid PII unless required. Email addresses, phone numbers, and full names should be omitted unless your use case explicitly requires them and you have the appropriate retention policies, access controls, and compliance posture in place.
- Sanitize before logging. If you log request payloads or event data, strip or hash sensitive fields before the log call, not after.
- Watch high-cardinality fields. Full URLs with query strings, raw user agents, or full IP addresses can increase query complexity. Be deliberate about what you include.
- Keep field names consistent. Inconsistent naming across services (userId vs user_id vs userID) breaks cross-service queries as reliably as missing fields do, just less visibly.
Logging secrets is a security incident. CloudWatch log groups are often readable by more people than your production database. A secret that ends up in a log line is effectively exposed to anyone with CloudWatch read access in that AWS account. Sanitize inputs and outputs before they reach the logger.
Common mistakes
- Putting variable data in the message field. Writing
“Processing order 789 for user 456”as the message means you cannot group logs by event type. Keep messages static:“Order processing failed”. PutorderIdanduserIdin named fields. - Inconsistent field names across services. If one Lambda logs
userIdand another logsuser_id, your cross-service Logs Insights queries will miss half the data. Agree on a schema before you start logging. - DEBUG logging in production. Debug logs are high-volume and often include more sensitive data than production logs should carry. Set the log level to INFO in production and control it with an environment variable so you can drop it temporarily without a deployment.
- Logging PII or secrets. Structured fields make it easy to log full event payloads, which often contain email addresses, tokens, or payment data. Sanitize before logging, not after.
- Using logs as a substitute for all metrics. Log-based metric filters work, but they add latency and process every log line. If you need a high-frequency numeric signal (orders per second, cache hit rate), publish it directly with CloudWatch Metrics rather than deriving it from logs.
- Inventing a different schema in every service. The value of structured logging compounds across services. A consistent schema means one Logs Insights query can span multiple log groups simultaneously.
How to roll this out safely
Switching an existing service to structured logging is low-risk when done incrementally:
- Define your shared field names as a team document before writing any code.
- Instrument one low-traffic Lambda or service first.
- Open CloudWatch Logs Insights, run a test query, and confirm your fields are discovered.
- Add
requestIdandcorrelationIdto every log event in that service. - Set a log retention policy on the log group. Ninety days is a reasonable default; indefinite retention is a common and expensive mistake.
- Create a metric filter on
level = “ERROR”and connect it to a CloudWatch Alarm. - Document your field naming conventions so the next service that gets instrumented follows the same schema.
- Roll out to remaining services one at a time.
Start with one service, not a big bang. Structured logging is easy to verify on a small scale: deploy, send a test request, open Logs Insights, and confirm your fields appear in the discovered fields panel. Once that works, the pattern is proven and rollout is mechanical.
Summary
- Structured logging means writing each log event as a JSON object with named fields, not free-form text.
- CloudWatch Logs Insights automatically discovers and parses JSON fields, enabling field-level filtering and aggregation without regex.
- Keep
messagestatic; put variable data in named fields likeorderIdorerrorCode. - Lambda Powertools Logger is the recommended path for Lambda. It adds execution context fields automatically and simplifies correlation ID propagation.
- A shared, consistent field naming schema across all services multiplies the value of every Logs Insights query you write.
- Structured logs make log-based metric filters and alarms faster to build and more reliable to maintain.
Frequently asked questions
What is structured logging in AWS?
Structured logging means writing each log event as a JSON object with named fields instead of free-form text. CloudWatch Logs Insights automatically parses JSON, so you can query by field value like filter errorCode = "PAYMENT_FAILED" without regex or scanning every character of every log line.
Why is JSON better than plain-text logs?
Named fields are searchable, aggregatable, and stable. A plain-text log breaks when the message format changes; a JSON log does not. You can group by errorCode, average durationMs, and correlate by requestId — none of which is reliably possible with free-form text.
Does Lambda support JSON logs natively?
Yes. Lambda has a built-in JSON log format you can enable in the function configuration. When enabled, Lambda emits platform events as JSON and wraps application output in a JSON envelope. For full control over field names and values in Python, print JSON strings explicitly or use Lambda Powertools.
When should I use Lambda Powertools instead of basic JSON logging?
Use Lambda Powertools when you want automatic Lambda context fields (cold_start, function_name, function_arn), easy correlation ID injection across services, and conventions your whole team can share. For a simple function where basic JSON output is enough, a custom formatter or native JSON logging works fine.
What fields should every structured log event include?
A good baseline: timestamp, level, message (a static string), service, environment, and requestId or correlationId. Add domain-specific fields like orderId, userId, errorCode, and durationMs wherever they apply.