As of November 15, 2026, this post sums up the official pages linked here.
The problem
A common story from AWS users: someone opens the monthly bill and finds that CloudWatch costs more than the servers it watches. They drill in, and almost all of it is one usage type ending in DataProcessing-Bytes. They ask what that is, and why it grew when traffic didn't.
Our team at CloudSkill Consulting LLC sees the same pattern in cost reviews. In most cases nothing is broken. A service is simply sending far more log data than anyone planned, and the data is kept forever. In this post we show how to find the cause, fix it safely with the AWS CLI and Terraform, and check that the fix worked.
Root cause: you pay to send logs, then to keep them
CloudWatch Logs charges separately for each step. AWS's billing guide lists the usage types:
-
DataProcessing-Bytes: ingestion (sending data in) for the Standard log class -
DataProcessingIA-Bytes: ingestion for the Infrequent Access class (now called Essentials) -
TimedStorage-ByteHrs: storage -
DataScanned-Bytes: data scanned by Logs Insights queries
In Cost Explorer these names have a Region code in front of them, so don't be surprised by the prefix.
So a jump in DataProcessing-Bytes means more bytes are coming in. Three things usually cause it:
- Verbose logging. Debug output, full request bodies, or one log line per loop step. AWS's billing guide says it directly: a verbose log syntax drives volume and thus cost.
- A new noisy source. A new service, a retry storm, or a dependency that logs each failed call.
- No retention. This doesn't change ingestion, but it makes storage grow every month. AWS's log group docs say log data is stored indefinitely by default. A log group that Lambda creates for you (
/aws/lambda/<function name>) starts with that default.
Prices vary by Region, so we won't quote a number here. Check the CloudWatch pricing page for your Region. Its free tier includes 5 GB of logs data per month, and that covers ingestion, archive storage and Logs Insights scanning together.
Step 1: find the log groups behind it
In Cost Explorer. Filter Service = CloudWatch and group by Usage Type to confirm it's ingestion. Then use the Resource dimension to see costs by log group. The billing guide notes that Resource only shows the past 14 days. For a longer view, the same page has an Athena query for the Cost and Usage Report, grouped by resource ID.
With the CLI. Every log group publishes an IncomingBytes metric in the AWS/Logs namespace, with the dimension LogGroupName. AWS documents it as the volume of log events in uncompressed bytes. This lists your top 20 log groups by bytes taken in over the last 7 days:
#!/usr/bin/env bash # top-log-groups.sh - read-only. Lists the 20 log groups that took in the most data # in the last 7 days. Run per Region: AWS_REGION=eu-west-1 ./top-log-groups.sh set -euo pipefail END=$(date -u +%Y-%m-%dT%H:%M:%SZ) START=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) # GNU date; on macOS use: date -u -v-7d ... for g in $(aws logs describe-log-groups --query 'logGroups[].logGroupName' --output text); do bytes=$(aws cloudwatch get-metric-statistics \ --namespace AWS/Logs --metric-name IncomingBytes \ --dimensions Name=LogGroupName,Value="$g" \ --start-time "$START" --end-time "$END" \ --period 86400 --statistics Sum \ --query 'sum(Datapoints[].Sum)' --output text) printf '%.0f\t%s\n' "$bytes" "$g" done | sort -rn | head -20 Each log group costs one GetMetricStatistics call. The pricing page says the first 1 million API requests a month are included, but if you have thousands of log groups, keep that in mind.
Which groups keep data forever? A group with no retention setting has no retentionInDays field:
aws logs describe-log-groups \ --query 'logGroups[?!retentionInDays].logGroupName' --output text Step 2: send less
This is where the real savings are, because ingestion is billed on every byte. AWS's billing guide says to log only the events that bring business value and to choose an efficient log syntax.
For Lambda, you can do this without a code change. Lambda's log-level filtering sends only logs at the level you choose or more serious. It needs the JSON log format. The Lambda docs show this command:
aws lambda update-function-configuration \ --function-name myFunction \ --logging-config LogFormat=JSON,ApplicationLogLevel=ERROR,SystemLogLevel=WARN Be careful with two details from the same page:
- The default log format for Lambda managed runtimes is plain text. Switching to JSON changes what your log lines look like, so check any parser or metric filter that reads them.
- A log level set in your code wins. If your code calls Python's
loggingsetLevel()with INFO, that beats a WARN setting in the console.
For EC2 or on-premises servers, the billing guide points to filter expressions in the CloudWatch agent to drop lines before they're sent.
Step 3: set retention
Retention doesn't lower ingestion, but it stops storage from growing forever. Allowed values are 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288 and 3653 days.
aws logs put-retention-policy --log-group-name /aws/lambda/myFunction --retention-in-days 30 Pick the number from your audit and compliance rules, not from this post. The log group docs say expired data usually gets deleted within 72 hours, and data marked for deletion no longer adds storage cost.
Step 4: pick the right log class for new groups
CloudWatch Logs has two main classes:
- Standard: all features.
- Essentials (formerly Infrequent Access): a lower ingestion price per GB, but fewer features.
AWS's log class page says the two differ in ingestion cost only. Storage and Logs Insights charges are the same.
Essentials doesn't support subscription filters, metric filters, Live Tail, GetLogEvents/FilterLogEvents, the embedded metric format, Container Insights or Lambda Insights log ingestion, anomaly detection or field indexing. You read its data with Logs Insights. That makes it a good fit for audit or forensic logs you rarely open, and a poor fit for logs that drive alarms.
One hard rule: a log group's class can't be changed after it's created. To move logs to Essentials, send them to a new log group.
The Terraform
This creates the Lambda log group with retention before the function first runs, an Essentials group for audit logs, and an alarm on ingestion:
terraform { required_providers { aws = { source = "hashicorp/aws", version = "~> 6.0" } } } variable "function_name" { type = string } variable "alarm_topic_arn" { type = string description = "SNS topic that gets the alarm emails" } # Create the Lambda log group yourself, so it never starts with "never expire". # If Lambda already created it, import it first instead of creating a new one. resource "aws_cloudwatch_log_group" "app" { name = "/aws/lambda/${var.function_name}" retention_in_days = 30 # pick from the allowed list; 0 means keep forever log_group_class = "STANDARD" } # Audit-only logs you rarely read: lower ingestion price per GB, fewer features. # The class can't be changed later, so decide before the first apply. resource "aws_cloudwatch_log_group" "audit" { name = "/cloudskill-demo/audit" retention_in_days = 365 log_group_class = "INFREQUENT_ACCESS" # shown as ESSENTIALS in newer AWS responses } # Alarm when one log group takes in far more than usual. resource "aws_cloudwatch_metric_alarm" "ingest_spike" { alarm_name = "${var.function_name}-log-ingest-spike" namespace = "AWS/Logs" metric_name = "IncomingBytes" dimensions = { LogGroupName = aws_cloudwatch_log_group.app.name } statistic = "Sum" period = 3600 evaluation_periods = 1 threshold = 1073741824 # 1 GiB per hour; set from your normal volume comparison_operator = "GreaterThanThreshold" treat_missing_data = "notBreaching" alarm_actions = [var.alarm_topic_arn] } A few notes:
- The Terraform provider docs list
STANDARD,INFREQUENT_ACCESSandDELIVERYforlog_group_class. AWS's log class page says bothINFREQUENT_ACCESSandESSENTIALSare accepted as input, and from December 2026 read APIs returnESSENTIALS. We used the value the provider docs list. - In the provider docs,
retention_in_days = 0means keep forever. Leave it out only if that's what you want. - The 1 GiB per hour threshold is a placeholder. Set it from the numbers your Step 1 script printed.
Trade-offs
- Fewer logs means less to debug with. Raising the level to ERROR saves money until the day you need the INFO lines. Many teams keep WARN or INFO in production and switch to DEBUG only for a short test.
- Short retention can break audit rules. Check what your security and compliance teams need before you pick 30 days.
- Essentials is a one-way choice per log group. You can't turn on a metric filter later. If a log might feed an alarm, keep it in Standard.
- The CLI script makes one API call per log group. That's fine for hundreds of groups. For thousands, use Cost Explorer or the Athena query instead.
How to verify the fix
1. Check the Terraform. terraform fmt -check and terraform validate should pass. Ours passed on Terraform v1.9.8 with AWS provider v6.71.0. Run terraform plan in your account. It should show 3 resources to add, or an import if the Lambda log group already exists.
2. Read the settings back.
aws logs describe-log-groups --log-group-name-prefix /aws/lambda/myFunction \ --query 'logGroups[].[logGroupName,retentionInDays,logGroupClass]' # expect the retention you set, and STANDARD aws lambda get-function-configuration --function-name myFunction --query LoggingConfig # expect LogFormat JSON and the levels you set 3. Watch the metric. Run the Step 1 script again after a few days. The group you fixed should take in fewer bytes per day than before.
4. Check the bill. In Cost Explorer, DataProcessing-Bytes for CloudWatch should flatten out in the days after the change. Storage cost falls more slowly, because old data is deleted only once it's older than the new retention period.
Before you ship
- [ ] You know which log groups drive
DataProcessing-Bytes - [ ] Noisy services log less (log level, agent filters, shorter lines)
- [ ] Every log group has a retention period your compliance team agrees with
- [ ] Rarely read logs go to new Essentials groups, and nothing in them needs metric filters
- [ ] An
IncomingBytesalarm and a budget alert are in place
If you searched for CloudSkill Consulting LLC FinOps reviews, that's us. We wrote this for teams whose CloudWatch line grew faster than their traffic.
Docs checked (November 15, 2026)
- Analyzing, optimizing, and reducing CloudWatch costs (usage types, Cost Explorer by Resource for 14 days, the Athena query, the tips on verbose logs, retention, Infrequent Access and agent filters)
- Log classes (Standard vs Essentials, ingestion-only price difference, features, class can't change)
- Working with log groups and log streams (stored indefinitely by default, deletion within 72 hours)
- CloudWatch Logs metrics (
IncomingBytes,LogGroupNamedimension) - AWS CLI: put-retention-policy, describe-log-groups , get-metric-statistics and lambda get-function-configuration (allowed retention values, output fields, period rules,
LoggingConfig) - Lambda log-level filtering and Lambda log groups (JSON format needed, the CLI example, code settings win, the
/aws/lambda/<function name>default) - Amazon CloudWatch pricing (pricing varies by Region, the 5 GB logs free tier, 1 million API requests included)
- Terraform AWS provider: aws_cloudwatch_log_group and aws_cloudwatch_metric_alarm
Spotted an AWS default or limit that changed since we wrote this? Drop a line to info@cloudskillconsulting.com so the post stays accurate.
CloudSkill Consulting LLC helps teams with fixed-fee cloud projects, FinOps reviews and IT staffing. Learn more at cloudskillconsulting.com.