CloudWatch Metric Filter Not Working? It Never Counts Old Logs: Case-Sensitive Patterns, JSON and Space-Delimited Syntax, Default Value, Dimensions and Tested Examples
When a CloudWatch metric filter is not working, the filter is usually fine and the expectation is wrong. The rule that surprises almost everyone: metric filters never look at old logs. They count only log events that arrive after the filter is created, so a new filter shows nothing until the next matching line is written, and the console's test against yesterday's logs proves nothing about today's metric. After that, the usual causes are a case-sensitive pattern (error does not match ERROR), a JSON pattern on a line that is not pure JSON (anything before the opening brace and it never matches), a log group in the Infrequent Access log class (metric filters work only on Standard), looking for the metric in the wrong namespace or with the wrong statistic, or a filter AWS has quietly disabled because its dimensions produced 1,000 different values. The fix starts with one command: test your pattern in seconds with aws logs test-metric-filter, then set a default value of 0 so the graph is not empty between matches, and graph the metric with the Sum statistic. This page walks through each cause, the full pattern syntax with examples, and creating filters in the CLI, Terraform and CloudFormation.
Jake wanted a text whenever the booking app failed to save a booking, so Ethan added a metric filter for "error" on the app's log group, an alarm on it, and went to bed. Two days later, a customer called to say her booking had vanished, and the alarm had never fired. The log line was right there: ERROR booking save failed. "So the alarm is broken?" Jake asked. "The alarm is fine," Ethan said. "It was waiting for a lowercase word our app never writes." This page is the list Ethan wished he had read before he went to bed.
New to CloudWatch? Your applications send log lines to log groups in CloudWatch Logs. A metric filter sits on a log group and watches every new line; when a line matches its pattern, it adds a number to a CloudWatch metric. Metrics can be graphed and alarmed on, logs cannot, so a metric filter is the bridge from "this text appeared in a log" to "text me when it happens".
The 60-second checklist
- Has a matching line arrived since you created the filter? Filters do not look backwards. If not, write one (below) and wait a minute or two.
- Is the pattern's case exactly right?
ERRORanderrorare different terms. - Does the pattern match a real line? Copy one line from the log group and run
aws logs test-metric-filterwith it. - Is the filter on the right log group, in the right Region and account, the one your app actually writes to?
- Is the log group in the Standard log class? Infrequent Access log groups do not support metric filters.
- Are you looking in the right place? The metric is in the namespace you typed (often a custom one, not
AWS/Logs), with the exact metric name, and you are graphing Sum over 1-minute or 5-minute periods. - Does the filter have dimensions? Then there is no default value, the graph is empty between matches, and a filter whose dimensions produced too many values may have been disabled.
- Is the alarm reading the metric correctly? Missing data between matches can leave an alarm in INSUFFICIENT_DATA; treat missing data as not breaching.
Work through it in order, because each step rules out a whole family of causes, and the first two take less than a minute. Most filters are fixed by step 1 or 2. Ethan's was step 2, plus step 6: he had been looking for the metric under AWS/Logs when he had put it in a namespace called BookingApp.
Why a new metric filter shows no data: it is not retroactive
A metric filter publishes data points only for log events that arrive after the filter was created. The thousand errors from last week are invisible to it, forever. That is why a brand-new filter on a quiet log group shows an empty graph and, if nothing has matched yet, does not even appear in the metrics list: CloudWatch creates the metric with its first data point.
Two consequences catch people out. The console's Test pattern step shows up to 50 matching lines from recent logs, which proves the pattern is right but says nothing about the metric, because those lines were ingested before the filter existed; lines older than the filter are not shown at all. And after you change a pattern, the metric changes only from that moment on, so a graph covering the last day mixes the old pattern's results with the new one's.
To see the metric work without waiting for a real error, write a matching line yourself. For a log group your own code writes to, a test line from the CLI does the job (the stream must exist first):
aws logs create-log-stream --log-group-name /booking/app --log-stream-name filter-test
aws logs put-log-events --log-group-name /booking/app --log-stream-name filter-test \
--log-events timestamp=$(date +%s000),message="ERROR booking save failed (filter test)"
Within a minute or two, the metric should show a data point. If you need counts for the past (for an incident review, or to tune an alarm threshold), use CloudWatch Logs Insights instead, which does read old logs: filter @message like /ERROR/ | stats count() by bin(5m).
Test a pattern against real log lines
The fastest way to end the guessing is test-metric-filter, which runs a pattern against sample lines you give it and shows which ones match, without creating anything:
aws logs test-metric-filter \
--filter-pattern 'ERROR' \
--log-event-messages "ERROR booking save failed" "error booking save failed" "INFO booking saved"
Only the first line comes back in matches, which is the case sensitivity in action. Always test with lines copied from the real log group, not with what you think the app writes; the console's Logs view, or aws logs filter-log-events --log-group-name /booking/app --limit 5, gives you real ones. For JSON logs, paste the line exactly as stored, on one line.
In the console, the same test is on the Define pattern step of Create metric filter: choose a log stream or paste custom lines under Test pattern, and the results table shows matches and any extracted fields.
Check a pattern against last week's logs with filter-log-events
Metric filters cannot look backwards, but the same pattern syntax works in log searches, which can. Before you rely on a pattern, run it over the past few days of the log group with filter-log-events; if it finds the errors you expected, the metric filter will catch the next ones:
aws logs filter-log-events --log-group-name /booking/app \
--filter-pattern '?ERROR ?Error' \
--start-time $(date -u -d '-3 days' +%s000) \
--query "events[].message" --output text | head -20
An empty result over a period you know had errors tells you the pattern is wrong before you build an alarm on it. The same syntax also drives Live Tail in the CloudWatch console, which streams matching lines as they arrive; leaving Live Tail open with your pattern while you trigger a test error is a quick way to watch the match happen in real time.
Filter pattern syntax for plain-text logs
For logs that are plain text, a pattern is a list of terms. These are the forms you will use most:
| Pattern | Matches lines that | Watch out |
|---|---|---|
ERROR | contain the term ERROR | Case-sensitive |
ERROR booking | contain both ERROR and booking, anywhere | Not a phrase; order does not matter |
"save failed" | contain the exact phrase | Quote phrases and terms with symbols |
?ERROR ?Exception | contain ERROR or Exception | ? terms are ignored if mixed with other terms |
ERROR -healthcheck | contain ERROR but not healthcheck | The minus excludes a term |
%[Ee]rror% | match the regular expression (Error or error) | Limited regex; no parentheses |
"" (empty quotes) or an empty pattern | every line | Useful for counting all lines |
Because patterns are case-sensitive, the safest way to catch an error however your code capitalizes it is either a regex (%ERROR|Error|error%, using the | alternation) or optional terms (?ERROR ?Error ?error). Regex in filter patterns is deliberately small: it must be wrapped in %, supports ^ $ ? [ ] { } | \ * + . and character classes like \d and \w, does not support parentheses or multi-byte characters, and a log group can have at most 5 filter patterns containing regex across its metric and subscription filters.
JSON log patterns, and why they so often never match
For structured logs, a JSON pattern selects fields with $. and compares them. A pattern is wrapped in braces:
{ $.level = "ERROR" } # a string field equals a value
{ $.statusCode >= 500 } # numeric comparison
{ $.level = "ERROR" && $.service = "booking" } # AND
{ $.level = "ERROR" || $.level = "FATAL" } # OR
{ $.error.code = "Timeout*" } # nested field, wildcard
{ $.items[0].sku = "SCREEN-*" } # array element (zero-based)
{ $.customerId NOT EXISTS } # the field is missing
{ $.retry IS TRUE } # boolean
The catch that makes JSON filters "not work": the pattern only applies if the whole log event is a JSON object. If anything sits in front of the opening brace, a timestamp, a log level, or a request ID added by a logging library, the event is plain text to CloudWatch and the JSON pattern never matches. A Lambda function using a logging library in the default text format writes lines like [ERROR] 2026-10-22T18:01:12Z 3f6c... {"msg": "save failed"}, which a JSON pattern cannot read. Three fixes:
- Switch the Lambda function's log format to JSON (in its logging configuration), so every line is a JSON object with fields such as
levelandmessage. - Configure your logging library to print pure JSON, with nothing before the brace.
- Or give up on fields and use a plain-text pattern such as
"save failed", which matches wherever the words appear.
Also remember that JSON patterns are case-sensitive on both field names and values ($.Level is not $.level), that string values in comparisons need double quotes, and that the variables IS NOT and EXISTS on their own are not supported; use NOT EXISTS.
Space-delimited patterns for access logs
Web server and load balancer logs are rows of fields separated by spaces. A space-delimited pattern names the fields in brackets, in order, and can put conditions on any of them:
# log line:
127.0.0.1 Prod frank [10/Oct/2000:13:25:15 -0700] "GET /index.html HTTP/1.0" 404 1534
# every 404:
[ip, server, user, timestamp, request, status_code=404, bytes]
# every 4xx or 5xx:
[ip, server, user, timestamp, request, status_code=4* || status_code=5*, bytes]
# large responses only:
[ip, server, user, timestamp, request, status_code, bytes>1000]
Text in square brackets and text in double quotes count as one field each, which is why the timestamp and the request line above are single fields. Conditions can use =, !=, <, >, <=, >=, the * wildcard and regex, combined with && and ||; a delimited pattern can hold at most 2 regexes. The usual failure is a field count that does not match the line, so a line with an extra field (a newer log format, a user agent with spaces outside quotes) silently stops matching. Count the fields in a real line and test it.
Ready-made patterns for the logs you probably have
You rarely need to invent a pattern from scratch. These cover the most common AWS log sources; copy one, then test it against a line from your own log group, because formats differ between versions and settings:
| You want to count | Log source | Pattern |
|---|---|---|
| Lambda timeouts | The function's log group (text format) | "Task timed out" |
| Errors from your Lambda code | The function's log group with JSON log format | { $.level = "ERROR" } |
| API 5xx responses | API Gateway access logs in JSON with a status field | { $.status >= 500 } |
| Rejected network traffic | VPC Flow Logs in the default format | [version, account, eni, source, destination, srcport, destport, protocol, packets, bytes, windowstart, windowend, action="REJECT", flowlogstatus] |
| Unauthorized API calls | CloudTrail delivered to CloudWatch Logs | { ($.errorCode = "*UnauthorizedOperation") || ($.errorCode = "AccessDenied*") } |
| Failed console sign-ins | CloudTrail | { ($.eventName = ConsoleLogin) && ($.errorMessage = "Failed authentication") } |
| Root user activity | CloudTrail | { $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" } |
The three CloudTrail patterns are the classic security alarms many compliance checklists ask for, and they are a good reason to send your trail to CloudWatch Logs in the first place. JSON patterns may use parentheses to group conditions, as these do; it is only inside a %...% regex that parentheses are not allowed. For the flow log pattern, the field list must match the format your flow log uses; if you created it with a custom format, list your fields in your order.
The string-or-number trap in API Gateway access logs
The API 5xx recipe above hides a trap worth its own section, because it applies to any JSON log you design yourself. API Gateway writes access logs in whatever format you give it, built from $context variables. Many examples quote every value:
{ "requestId":"$context.requestId", "status":"$context.status", "latency":"$context.responseLatency" }
That produces "status":"502", a string. A numeric pattern such as { $.status >= 500 } compares numbers, so with quoted values you either match the string with a wildcard, { $.status = "5*" }, or change the format to write the number unquoted, "status":$context.status, which produces "status":502 and lets numeric comparisons and a numeric metricValue such as $.latency work. Decide once, write it down, and make every pattern on that log group follow the same choice; a mix of quoted and unquoted numbers across services is a reliable way to get filters that match in one place and not another.
The same rule applies to your own JSON logs: write numbers as numbers and booleans as true or false without quotes, and both your metric filters and your Logs Insights queries become simpler.
Metric value, default value and units
Every filter publishes a metric value for each matching line. The two common choices:
1: count matching lines. Graph it with the Sum statistic to see how many matched per period. (Average of a count is always 1, which is why a graph set to Average looks flat and useless.)- A field from the line, such as
$.latencyfor JSON or$bytesfor a space-delimited pattern: publish the number itself, then graph Average, Maximum or a percentile such as p99.
The default value is what the filter publishes for a minute in which logs arrived but none matched. Set it to 0 so the metric reads "zero errors" instead of "no data", which gives you continuous graphs and simpler alarms. Two limits apply: if no logs at all arrive in a minute, nothing is published even with a default; and a filter with dimensions cannot have a default value. Percentile statistics work only if the metric's values are never negative.
If you are unsure which to pick, start with a count: it is the right answer for errors, timeouts and rejected requests, and you can always add a second filter for values later. Choose the unit (Count, Milliseconds, Bytes...) correctly the first time. Changing a filter's unit later has no effect on the metric, so a wrong unit means creating a new metric name.
Turn numbers in your logs into latency and size metrics
Counting lines is only half of what metric filters do. If your logs carry a number, you can publish the number itself and get averages and percentiles from it. With JSON logs such as {"level":"INFO","route":"/book","durationMs":182}:
aws logs put-metric-filter --log-group-name /booking/app \
--filter-name booking-latency \
--filter-pattern '{ $.route = "/book" && $.durationMs = * }' \
--metric-transformations metricName=BookingLatency,metricNamespace=BookingApp,metricValue='$.durationMs',unit=Milliseconds
Graph BookingLatency with the p99 or p95 statistic to see your slowest requests, and Average for the typical one. Leave the default value out here: a default of 0 would pull your averages down in quiet minutes, because "no requests" would be published as "requests that took 0 ms". For space-delimited logs, the same idea uses a field name, for example metricValue=$bytes on an access log pattern to track response sizes. The value must be numeric in the log line; a field that sometimes holds text ("durationMs":"n/a") does not publish for those lines.
Dimensions: useful, limited, and the reason filters get disabled
A filter on JSON or space-delimited logs can add up to 3 dimensions, taken from fields in the line, for example "service": $.service. Each distinct value becomes a separate metric, so you can graph errors per service. Each of those is billed as a separate custom metric, which is why the choice matters:
- Pick fields with a handful of values (service name, environment, status class), never IDs, IP addresses or user names.
- If a filter generates 1,000 different name/value pairs for its dimensions within a period, CloudWatch Logs disables the filter to protect you from a runaway bill. The filter still exists, but it stops publishing, which looks exactly like "the filter stopped working last Tuesday".
- A filter with dimensions has no default value, so between matches each dimension's metric shows no data.
If a filter with dimensions went quiet, check whether a field you used as a dimension suddenly started carrying unique values (a new release that puts request IDs into the "service" field, for example), fix the field or remove the dimension, and recreate the filter. Our custom metrics guide shows what each metric costs.
Create a metric filter: console, CLI, Terraform and CloudFormation
In the console, open Logs, Log groups, select the group, choose Actions, Create metric filter, enter and test the pattern, then set the namespace, metric name, value and default value. From the CLI:
aws logs put-metric-filter \
--log-group-name /booking/app \
--filter-name booking-errors \
--filter-pattern '?ERROR ?Error' \
--metric-transformations metricName=BookingErrors,metricNamespace=BookingApp,metricValue=1,defaultValue=0,unit=Count
aws logs describe-metric-filters --log-group-name /booking/app
Running put-metric-filter again with the same filter name updates the filter. A log group can have up to 100 metric filters. In Terraform:
resource "aws_cloudwatch_log_metric_filter" "booking_errors" {
name = "booking-errors"
log_group_name = aws_cloudwatch_log_group.booking_app.name
pattern = "{ $.level = \"ERROR\" }"
metric_transformation {
name = "BookingErrors"
namespace = "BookingApp"
value = "1"
default_value = "0"
unit = "Count"
}
}
And in CloudFormation, where quotes inside the pattern need care in YAML:
BookingErrorsFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref BookingAppLogGroup
FilterName: booking-errors
FilterPattern: '{ $.level = "ERROR" }'
MetricTransformations:
- MetricName: BookingErrors
MetricNamespace: BookingApp
MetricValue: "1"
DefaultValue: 0
Unit: Count
Escaping is the most common reason a pattern that worked in the console fails in code: the shell, HCL and YAML each treat quotes and $ differently. After deploying, run describe-metric-filters and compare the stored pattern with the one you tested, character by character.
An error alarm from scratch, step by step
If you are starting fresh, this is the full path from "my app logs errors" to "my phone buzzes", in the order that avoids every trap on this page:
- Make sure your log group is Standard class. Check it in the log group's details; if it is Infrequent Access, create a new Standard group and point your app at it.
- Copy two or three real error lines from the log group, plus one normal line.
- Write and test the pattern with
aws logs test-metric-filteruntil it matches the error lines and not the normal one. - Create the filter with a metric value of 1, a default value of 0, a unit of Count, and a namespace you will remember.
- Write a test line with
put-log-events(or trigger a harmless error in your app) and confirm the metric shows a data point with the Sum statistic. - Create an SNS topic with your email or phone as a subscriber, and confirm the subscription.
- Create the alarm on the metric: Sum over 1 minute, greater than 0, missing data not breaching, action to the topic.
- Write one more test line and wait for the notification. Only now is the alarm proven.
It takes about fifteen minutes the first time and five after that. The steps you skip are the ones that leave you, like Ethan, finding out from a customer instead of from the alarm.
Metric filter, Logs Insights, subscription filter or EMF?
Metric filters are one of four ways to get numbers or actions out of your logs, and picking the right one saves you work:
- Metric filter: you want a live count or value from logs you may not control (a third-party app, an access log) and an alarm on it. Cheap, simple, not retroactive.
- Logs Insights: you want answers about the past, ad hoc ("how many failures last Tuesday, by customer?"). Reads old logs, priced per GB scanned, no alarms on its own.
- Subscription filter: you want the matching log events themselves delivered somewhere (a Lambda function, Kinesis, OpenSearch) to act on each one. A log group can have up to 5 subscription filters, and they use the same pattern syntax.
- Embedded Metric Format (EMF): you own the code and want rich custom metrics with dimensions, written as structured log lines that CloudWatch turns into metrics automatically. No pattern to maintain; our custom metrics guide shows it in practice.
For your own application code, EMF or direct metrics are usually the cleaner choice for business numbers (bookings per minute), while metric filters are the quickest way to alarm on error text. Jake's app ended up with both: EMF for booking counts, a metric filter for errors.
Create and test filters with Python (boto3)
If you manage many log groups, a short script keeps patterns consistent and tests them before they go live. This tests a pattern against sample lines and creates the filter only if every expected line matches and every other line does not:
import boto3
logs = boto3.client("logs")
PATTERN = "?ERROR ?Error"
SHOULD_MATCH = ["ERROR booking save failed", "Error: card declined"]
SHOULD_NOT = ["INFO booking saved", "WARN slow response"]
result = logs.test_metric_filter(filterPattern=PATTERN, logEventMessages=SHOULD_MATCH + SHOULD_NOT)
matched = {m["eventMessage"] for m in result["matches"]}
assert matched == set(SHOULD_MATCH), f"pattern matched {matched}"
for group in ["/booking/app", "/booking/worker"]:
logs.put_metric_filter(
logGroupName=group, filterName="errors", filterPattern=PATTERN,
metricTransformations=[{"metricName": "Errors", "metricNamespace": "BookingApp",
"metricValue": "1", "defaultValue": 0.0, "unit": "Count"}])
print("filter set on", group)
Running the same test in your deployment pipeline means a pattern change that silently stops matching is caught before it ships, not after the next incident. Note that defaultValue is a number in the API while metricValue is a string, because it can also be a field reference such as $.durationMs.
Where your logs come from decides what the pattern sees
The pattern runs against each log event exactly as it was stored, so it helps to know what each source writes:
- Lambda: one event per line your code prints, plus Lambda's own START, END and REPORT lines and messages such as "Task timed out". With the JSON log format, Lambda's own lines become JSON too, so a pattern on
$.levelcan also catch platform errors. - ECS and Fargate with the awslogs driver: one event per line of the container's standard output and standard error, without any added prefix, so a JSON logger in your container gives you pure JSON events.
- EC2 with the CloudWatch agent: one event per line of each file you collect, unless you configure a multi-line start pattern; a stack trace is then one event instead of thirty, which keeps "Exception" counts honest.
- AWS services (VPC Flow Logs, CloudTrail, API Gateway access logs, Route 53 query logs): each has its own fixed or configurable format; read one event before writing the pattern.
The multi-line point is worth checking even on Lambda and containers: if your error handler logs a stack trace one line at a time, a pattern on "Exception" counts every line that mentions it, and one failure looks like five. Log errors as a single structured event and the count becomes meaningful.
Updating, renaming and deleting filters
A few behaviors are worth knowing before you change a filter that alarms depend on:
- Updating (running
put-metric-filterwith the same filter name) changes the pattern from that moment; past data points stay as they were. - Renaming the metric or changing its namespace creates a new metric. Alarms on the old name stop receiving data and drift into INSUFFICIENT_DATA, so update them in the same change.
- Deleting a filter (
aws logs delete-metric-filter --log-group-name /booking/app --filter-name booking-errors) stops new data points; the metric's history stays in CloudWatch for its normal retention of up to 15 months. - Several filters on one log group work independently, and one log line can feed many of them, so you can keep an "all errors" filter and a narrower "payment errors" filter side by side.
Find and graph the metric
The metric lives where you put it: Metrics, All metrics, then your custom namespace (for example BookingApp), then Metrics with no dimensions or the dimension set you configured. It is not under AWS/Logs. From the CLI:
aws cloudwatch list-metrics --namespace BookingApp
aws cloudwatch get-metric-statistics --namespace BookingApp --metric-name BookingErrors \
--start-time $(date -u -d '-1 hour' +%FT%TZ) --end-time $(date -u +%FT%TZ) \
--period 300 --statistics Sum
If list-metrics returns nothing, no line has matched since the filter was created (or the filter is disabled); go back to the test line. If it lists the metric but the graph looks empty, widen the time range, set the statistic to Sum and the period to 1 or 5 minutes. Data points usually appear within a couple of minutes of the log line; metric filters guarantee at least one delivery, so an occasional duplicate count is possible, which matters for billing-style counts more than for alerts.
Alarm on a metric filter without false alarms or silence
An error alarm built on a metric filter has one design question: what should a minute with no errors look like? With a default value of 0, quiet minutes are zeros and the alarm can use the normal "greater than 0" threshold. Without one (always the case with dimensions), quiet minutes are missing data, so set the alarm's missing data treatment to notBreaching, or the alarm sits in INSUFFICIENT_DATA:
aws cloudwatch put-metric-alarm --alarm-name booking-errors \
--namespace BookingApp --metric-name BookingErrors \
--statistic Sum --period 60 --evaluation-periods 1 \
--threshold 0 --comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:111122223333:ops-alerts
Our INSUFFICIENT_DATA guide covers the alarm side in depth. For Jake's app, Ethan set the alarm to fire on any booking error in a minute, sent through SNS to a phone number, and tested it with the CLI test line before calling it done.
Name filters and metrics so you can find them later
A year from now, you will want to know which filter feeds which alarm without opening each one. A few conventions help:
- Use one namespace per application (
BookingApp), not one per filter, so every metric for the app is in one place in the console. - Name the metric for what it counts (
BookingErrors,PaymentTimeouts), and give the filter the same name in lower case, sodescribe-metric-filtersoutput reads like a list of your metrics. - Put the pattern in your infrastructure code next to the alarm that uses it, with the sample lines it must match in a comment or test, so a change to one prompts a look at the other.
- Tag the log group with the team that owns it; the filter inherits nothing, but the log group's owner is who you ask when a pattern stops matching after a release.
Log classes, transformation and central logging
Three newer CloudWatch Logs features change where metric filters work:
- Log classes. Metric filters are supported only for log groups in the Standard class. A log group created in the cheaper Infrequent Access class cannot have them, and the class cannot be changed after creation, so a log group you want to alarm on must be created as Standard. Our log ingestion cost guide explains when Infrequent Access is worth it.
- Log transformation. If a log group transforms events during ingestion, filter patterns are applied to the transformed events, so write the pattern against the output of the transformer, not the raw line.
- Centralized logs. In a central monitoring account that collects logs from other accounts, metric filters can use the system fields
@aws.accountand@aws.regionas dimensions, to count errors per source account or Region.
Standard-class ingestion costs $0.50 per GB in US East (N. Virginia), October 2026, and storage $0.03 per GB-month; the metric filter itself adds only the custom metric charges for the metrics it publishes.
Metric filter still not working? Troubleshooting by symptom
The metric never appears in the metrics list
Nothing has matched since the filter was created, or the filter is on a different log group. Write a matching test line to the right group and check again in a couple of minutes.
The console test shows matches but the metric is empty
The test used logs from before the filter existed. Only new lines count; write a fresh one.
My JSON pattern matches nothing
The lines are not pure JSON (a prefix before the brace) or a field name's case differs. Switch the log format to JSON or use a plain-text pattern.
The filter worked, then stopped
A dimension produced 1,000 different values and CloudWatch disabled the filter, or the log format changed with a release. Check the latest lines and the dimension fields.
"Invalid filter pattern" when creating the filter
Unbalanced quotes or braces, parentheses in a regex, or shell escaping. Test the pattern with test-metric-filter and quote it with single quotes in the shell.
I cannot create a metric filter on this log group
The log group is in the Infrequent Access class, which does not support metric filters, or it already has 100. Create a Standard log group for logs you need to alarm on.
The graph shows 1 all the time
The statistic is Average. Use Sum for counts.
Counts are slightly higher than the log lines
Metric filters deliver at least once, so rare duplicates happen. For exact counts, use Logs Insights over the time range.
The filter disappeared after a redeploy
Metric filters belong to their log group, so deleting and recreating the log group (which some stacks do on replacement) deletes its filters too. Define the filter in the same infrastructure code as the log group, so they are always created together.
The filter exists but the app now logs to a new log group
A renamed function, service or stack writes to a different log group name. Point the filter at the new group, or keep log group names stable across deploys.
One failure is counted many times
The error is logged as several lines (a stack trace) and each one matches. Log errors as one structured event, or configure multi-line handling in the agent.
The latency metric's average looks too low
A default value of 0 is being published in quiet minutes. Remove the default value from filters that publish measured values.
The pattern works in the console but not from Terraform or CloudFormation
Quotes or the dollar sign were changed by escaping. Compare the stored pattern from describe-metric-filters with the tested one.
The alarm never leaves INSUFFICIENT_DATA
Quiet periods publish no data (no default value, or dimensions). Set treat-missing-data to notBreaching or add a default value of 0.
CloudWatch metric filters: frequently asked questions
Why is my CloudWatch metric filter not working?
Usually because no matching line has arrived since it was created, the pattern's case is wrong, or a JSON pattern is used on lines that are not pure JSON. Test with aws logs test-metric-filter and write a fresh test line.
Do CloudWatch metric filters work on existing logs?
No. Metric filters only count log events ingested after the filter was created. Use CloudWatch Logs Insights to count past events.
Are CloudWatch filter patterns case-sensitive?
Yes. ERROR and error are different terms. Use a regex such as %ERROR|Error|error% or optional terms to catch every case.
How do I test a CloudWatch metric filter pattern?
Run aws logs test-metric-filter with the pattern and sample lines copied from the log group, or use Test pattern in the console when creating the filter.
How do I write a metric filter for JSON logs?
Use a JSON pattern such as { $.level = "ERROR" }. It only works when each log event is a complete JSON object with nothing before the opening brace.
What is the default value in a metric filter?
The value published for a minute in which logs arrived but none matched. Setting it to 0 avoids gaps in the metric; filters with dimensions cannot have one.
Where does the metric from a metric filter appear?
In the namespace you chose when creating the filter, under All metrics in the CloudWatch console. It appears only after the first matching line.
How many metric filters can a log group have?
Up to 100 metric filters per log group. A log group can also have at most 5 filter patterns that use regex.
How many dimensions can a metric filter have?
Up to three, from fields in JSON or space-delimited logs. A filter is disabled if its dimensions generate 1,000 different values.
How do I count 5xx errors in access logs with a metric filter?
Use a space-delimited pattern naming every field, for example [ip, server, user, timestamp, request, status_code=5*, bytes], with a metric value of 1.
How do I match ERROR or Exception in one filter?
Use optional terms: ?ERROR ?Exception. Optional terms cannot be mixed with required or excluded terms in the same pattern.
How do I exclude a term from a metric filter?
Put a minus before it, for example ERROR -healthcheck. In space-delimited patterns, use != on a field.
Can I use regex in CloudWatch filter patterns?
Yes, wrapped in percent signs, such as %[Ee]rror%. The supported syntax is limited and does not include parentheses.
Do metric filters work with the Infrequent Access log class?
No. Metric filters are supported only for log groups in the Standard log class.
How much does a CloudWatch metric filter cost?
The filter has no separate charge; the metrics it publishes are billed as custom metrics, and each dimension value is a separate metric. Log ingestion is billed as usual.
Why does my metric filter graph show only 1?
The graph uses the Average statistic, and each match publishes 1. Switch the statistic to Sum to see counts.
For readers preparing for an AWS certification
We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.
Exams that test this topic: CloudOps Engineer – Associate (SOA-C03) • Developer – Associate (DVA-C03 from December 1, 2026) • Security – Specialty (SCS-C03).
Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:
1. A new metric filter on a log group shows no data although last week's logs contain many matching lines. Why?
Metric filters are not retroactive; they only count log events ingested after the filter was created.
2. A filter pattern of error never matches log lines containing ERROR. What is wrong?
Filter patterns are case-sensitive; use ERROR, optional terms or a regex that covers every case.
3. Which pattern counts JSON log events whose level field is ERROR?
{ $.level = "ERROR" }, which works only if each log event is a complete JSON object.
4. An error alarm built on a metric filter stays in INSUFFICIENT_DATA during quiet periods. What fixes it?
Set a default value of 0 on the metric filter, or set the alarm to treat missing data as notBreaching.
5. The security team needs an alarm for unauthorized API calls across the account. Which combination works?
Send CloudTrail to CloudWatch Logs, add a metric filter on errorCode values such as AccessDenied* and *UnauthorizedOperation, and alarm on the metric.
6. A metric filter with a requestId dimension stopped publishing. What happened?
High-cardinality dimensions generated 1,000 different values, so CloudWatch Logs disabled the filter.
Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.
The new filter, ?ERROR ?Error with a default value of 0, caught its first real failure the next Monday, within two minutes of the log line being written, and Jake's phone buzzed before the customer had finished retyping her booking. Jake asked if that meant the app was getting worse. "It means we finally hear about it," Ethan said. "The app was this bad on Saturday too."
📌 If you keep one line from this page
A metric filter counts only new log lines, matches case-sensitively, reads JSON only when the whole line is JSON, and works only on Standard log groups: test the pattern with real lines, write a fresh test line, set a default of 0, and graph Sum in your own namespace.
Not retroactive · 100 filters per log group · 3 dimensions, disabled at 1,000 values · 5 regex patterns per log group.
Revision note. Written October 22, 2026, with CloudWatch Logs filter pattern rules, quotas and US East (N. Virginia) prices of that date. "Measure twice, cut once," the proverb says; with metric filters, test twice, alarm once.