With usage-billed observability services like CloudWatch, cost optimization starts with your code. It's not just that applications publish the metrics; applications spend. Every PutMetricData call, every dimension, every dashboard refresh contributes to your bill—on both sides of the pipe, because CloudWatch charges to put metrics in and to get them back out.
The pricing mechanics that matter: a custom metric is every unique combination of metric name and dimension values, at ~$0.30/metric/month for the first 10,000. The PutMetricData API bills ~$0.01 per 1,000 calls. Reading bills too—GetMetricData at ~$0.01 per 1,000 metrics requested—and alarms (~$0.10 each) and dashboards (~$3 each) round out the bill. Four different meters, and application code drives all of them.
Here's a practical walk-through of what to look for and how to optimize CloudWatch metrics costs at the code and configuration level.
| Cost Trap | Efficiency Pattern |
|---|---|
| Custom metrics duplicating built-in metrics | Use the free metrics AWS already publishes |
| High-cardinality dimensions | Dimension with bounded sets; never IDs |
| Ephemeral dimensions | Don't dimension by instance/container/deploy identity |
| One datapoint per PutMetricData call | Batch with Values/Counts arrays and MetricData lists |
| Sub-minute publishing everywhere | Publish at 60s; reserve high-resolution for what needs it |
| High-volume metrics via the API | Use Embedded Metric Format through logs |
| Chatty dashboard and tool polling | Tune refresh rates and query scope on readers |
| Alarms and dashboards on dead metrics | Audit and delete the orphans |
| Detailed monitoring enabled fleet-wide | Enable per-service where 1-minute data earns its cost |
Attribute the Costs
Start with your bill. Break down CloudWatch spend by usage type—the bill separates custom metrics (MetricMonitorUsage), API requests (PutMetricData, GetMetricData/GMD-Metrics), alarms, and dashboards, per account and region. Each line points at a different fix: metric-count charges point at cardinality in your dimensions, PutMetricData charges point at unbatched publishing, GetMetricData charges point at whatever's polling you (usually a third-party observability tool or an over-eager Grafana), and a long tail of alarm charges points at monitors nobody has looked at since the service they watched was decommissioned.
Then attribute the metric count to code. List metrics by namespace (aws cloudwatch list-metrics --namespace MyApp) and look at the distribution: one metric name exploding into thousands of entries means one dimension whose values don't stop. This granular attribution reveals which of the 9 efficiency patterns below deliver the highest impact.
Tackling Metric Count Costs
At $0.30 per metric per month, cardinality is the expensive meter.
Use the free built-ins first
Cost impact: Custom metric count
AWS publishes free metrics for EC2, RDS, Lambda, ELB, ECS, DynamoDB, and most everything else—CPU, network, request counts, error rates. Publishing your own copy pays $0.30/month per series for data that's already on the shelf:
# Bad: hand-rolled CPU metric per instance ($0.30/month each)
cloudwatch.put_metric_data(
Namespace='MyApp',
MetricData=[{'MetricName': 'CPUUtilization',
'Dimensions': [{'Name': 'InstanceId', 'Value': iid}],
'Value': cpu_percent}])
# Good: delete the code. AWS/EC2 CPUUtilization is free.
Before instrumenting anything infrastructure-shaped, check the service's built-in metric list. Custom metrics are for what only your application knows—orders placed, jobs queued, cache hit rate.
Benefit: Deleted metrics cost nothing and were duplicates anyway.
Dimension with bounded sets, never IDs
Cost impact: Custom metric count (multiplicative)
Every unique dimension-value combination is a separate $0.30/month metric, and dimensions multiply. Endpoint (30) × StatusFamily (5) is 150 metrics—fine. Add RequestId and you've built a metric-minting machine:
# Bad: a new billable metric per request
MetricData=[{'MetricName': 'Latency',
'Dimensions': [{'Name': 'RequestId', 'Value': request_id}],
'Value': ms}]
# Good: bounded dimensions; IDs belong in logs and traces
MetricData=[{'MetricName': 'Latency',
'Dimensions': [{'Name': 'Endpoint', 'Value': route_template},
{'Name': 'StatusFamily', 'Value': f'{code//100}xx'}],
'Value': ms}]
The same trap wears an infrastructure costume: dimensions like InstanceId, ContainerId, or a per-deploy Version churn with every restart and rollout, so the metric count grows with churn even when traffic doesn't. Dimension by stable identity (Service, Environment) and keep one bounded Version window if you need deploy comparisons. The rule for both: if you can't enumerate a dimension's values in advance, it doesn't belong on a metric.
Benefit: The difference between hundreds of metrics and hundreds of thousands.
Tackling Publishing Costs
Batch PutMetricData
Cost impact: API requests
PutMetricData bills per call, and one datapoint per call is the default nobody meant to choose. A call carries up to 1,000 datapoints across the MetricData list, and the Values/Counts arrays compress repeated observations further:
# Bad: one API call per observation (86M calls/day at 1,000 rps)
for ms in latencies:
cloudwatch.put_metric_data(Namespace='MyApp',
MetricData=[{'MetricName': 'Latency', 'Value': ms}])
# Good: buffer, then one call per flush interval
cloudwatch.put_metric_data(
Namespace='MyApp',
MetricData=[{
'MetricName': 'Latency',
'Values': [12.1, 14.7, 9.3, ...], # up to 150 distinct values
'Counts': [40, 22, 118, ...], # with occurrence counts
'StorageResolution': 60
}])
Benefit: 100-1000x fewer PutMetricData calls; 80-99% of the API line item.
Publish at 60 seconds unless something needs faster
Cost impact: API requests
High-resolution metrics (1-second granularity) multiply publish calls up to 60x, and almost nothing downstream justifies it: dashboards render minutes, most alarms evaluate minutes. Publish at standard 60-second resolution by default and reserve high-resolution—with its matching ~$0.30 high-resolution alarms—for the few signals where sub-minute reaction time is the actual requirement (spiky autoscaling triggers, circuit breakers).
Benefit: Up to 60x fewer publish calls for signals nobody reads at 1-second grain.
Use Embedded Metric Format for high-volume metrics
Cost impact: API requests
For high-throughput services—especially Lambda—skip the metrics API entirely. Embedded Metric Format (EMF) writes a structured JSON line to the log stream you already have, and CloudWatch extracts the metrics asynchronously. No PutMetricData calls, no SDK latency in the request path, and the log line doubles as a queryable record:
# Good: metrics ride the log pipe (aws-embedded-metrics library)
from aws_embedded_metrics import metric_scope
@metric_scope
def handler(event, context, metrics):
metrics.set_namespace('MyApp')
metrics.put_dimensions({'Endpoint': route})
metrics.put_metric('Latency', elapsed_ms, 'Milliseconds')
You pay log ingestion on the EMF lines (~$0.50/GB—keep them lean), but the per-call metric API cost disappears and batching comes free with the log pipeline.
Benefit: Eliminates PutMetricData costs and API latency for high-volume publishers.
Tackling Read-Side Costs
Tune whatever's polling you
Cost impact: GetMetricData requests
The read meter runs quietly until a third-party tool makes it interesting: an external observability platform syncing every metric in the account, or a wall-mounted Grafana refreshing 40 panels every 10 seconds, bills GetMetricData per metric requested, around the clock. Scope third-party integrations to the namespaces they actually chart, drop dashboard refresh to 60s+ (matching the resolution of the data—refreshing faster than the metrics update buys literally nothing), and check the GMD usage line after any new tool connects.
Benefit: Often thousands of dollars a month on accounts with external pollers, for zero visibility loss.
Delete orphaned alarms and dashboards
Cost impact: Alarms and dashboards
Alarms outlive their services. At ~$0.10 each they accumulate politely—until an account audit finds four hundred of them watching metrics that stopped reporting years ago, plus dashboards at $3 apiece nobody has opened since. Alarms in INSUFFICIENT_DATA state for months are the tell; list them, match against live metrics, delete the orphans.
Benefit: Pure waste, recovered with a list-and-delete session.
Enable detailed monitoring deliberately
Cost impact: Custom-metric-priced platform metrics
EC2 detailed monitoring upgrades instance metrics from 5-minute to 1-minute resolution—at custom-metric prices per instance. Enabled fleet-wide by a Terraform default, it's a per-instance surcharge on boxes whose graphs nobody reads at minute grain. Enable it where autoscaling responsiveness or tight SLOs need 1-minute data; leave the batch fleet on the free 5-minute tier.
Benefit: A per-instance monthly surcharge trimmed to the instances that earn it.
Closing Thoughts
Cost-effective CloudWatch metrics requires two steps. First, let observed costs guide what needs optimization. The bill itself does the attribution: metric-count charges point at dimension cardinality, PutMetricData charges at unbatched or high-resolution publishing, GetMetricData charges at whatever's polling you, alarm and dashboard lines at accumulated orphans. Trace each back to the code and configuration minting it. This granular attribution reveals which of the 9 efficiency patterns above matter most. Second, apply the optimizations that address your concentrations: delete duplicates of free built-ins, bound every dimension, batch publishing with Values/Counts (100-1000x fewer calls), default to 60-second resolution, move high-volume publishers to EMF, tame the pollers, and sweep the orphaned alarms quarterly.
You don't lose observability—you lose spend on metrics nobody reads, at resolutions nothing requires, delivered one datapoint at a time. Instrument what only your application knows, and let AWS bill you for that instead.
Looking for help with cost optimizations like these? Book a Demo or Explore the Sandbox to see Frugal in action. Frugal attributes AWS CloudWatch metrics costs to your code, finds inefficient usage, provides Frugal Fixes that reduce your bill, and helps keep you out of cost traps for new and changing code.