PromQL Validator
Comprehensive toolkit for validating, optimizing, and understanding Prometheus Query Language (PromQL) queries. Use this skill when working with PromQL queries, prometheus queries, metrics queries, alerting rules, recording rules, or grafana dashboards to check syntax, detect anti-patterns, identify optimization opportunities, and interactively plan queries with users.
npx skills add github:pantheon-org/tekhne --skill observability--promql--validatorWhen to Use This Skill
Use this skill when you need to:
- Validate PromQL query syntax and semantics.
- Detect anti-patterns and high-cardinality risks in queries or dashboards.
- Review alerting or recording rules before they ship.
- Explain what a query does and compare it against the intended goal.
- Suggest optimisations such as recording rules or tighter label matchers.
When Not to Use
- Authoring a new query from scratch: use
promql-generatorinstead. - Non-PromQL query languages: use the tool that matches that language.
- Diagnosing Prometheus server health or scrape failures: that is an operations concern, not query validation.
- Confirming that a metric or label value exists in a live deployment: the scripts cannot check this, so test against a real Prometheus instance.
Philosophy
- Valid is not production-ready. Syntactic validity says nothing about correctness, cost, or intent.
- Know the metric type first. A query cannot be judged without knowing whether its series are counters, gauges, or histograms.
- Guard cardinality. A dashboard query that keeps a high-cardinality label can overload the TSDB; flag it.
- Alerts need a for clause. An expression with no sustain window flaps; check for one where it is warranted.
- Cite, do not assert. Back each correction with a worked example from
assets/or a section ofreferences/.
Workflow
This skill uses a two-phase interactive workflow: Phase 1 (Steps 1-4) presents validation results and asks clarifying questions, then stops and waits for user response before proceeding to Phase 2 (Steps 5-7) for tailored recommendations.
When a user provides a PromQL query, follow this workflow:
Step 1: Validate Syntax
Run the syntax validation script to check for basic correctness:
./scripts/validate_syntax.py "<query>"
Step 2: Check Best Practices
Run the best practices checker to detect anti-patterns and optimization opportunities:
./scripts/check_best_practices.py "<query>"
Step 3: Explain the Query
Parse and explain what the query does in plain English:
- What metrics are being queried and their types (counter, gauge, histogram, summary)
- What functions are applied and what the query calculates
- Output Labels: [list labels in result, or “None (fully aggregated)”]
- Expected Result Structure: [instant/range vector/scalar] with [N series/single value]
Step 4: Interactive Query Planning
Ask the user clarifying questions to verify the query matches their intent:
- Understand the Goal: “What are you trying to monitor or measure?”
- Verify Metric Type: “Is this a counter (always increasing), gauge (can go up/down), histogram, or summary?”
- Clarify Time Range: “What time window do you need?”
- Confirm Aggregation: “Do you need to aggregate data across labels? If so, which labels?”
- Check Output Intent: “Are you using this for alerting, dashboarding, or ad-hoc analysis?”
⏸️ STOP HERE AND WAIT FOR USER RESPONSE before proceeding to Steps 5-7.
Step 5: Compare Intent vs Implementation
After understanding the user’s intent:
- Explain what the current query actually does
- Highlight any mismatches between intent and implementation
- Suggest corrections if the query doesn’t match the goal
- Offer alternative approaches if applicable
When relevant, mention known limitations:
- Note when metric type detection is heuristic-based (e.g., “The script inferred this is a gauge based on the
_bytessuffix. Please confirm if this is correct.”) - Acknowledge when high-cardinality warnings might be false positives (e.g., “This warning may not apply if you’re using a recording rule or know your cardinality is low.”)
Step 6: Offer Optimizations
Based on validation results:
- Suggest more efficient query patterns
- Recommend recording rules for complex/repeated queries
- Propose better label matchers to reduce cardinality
- Advise on appropriate time ranges
Reference Examples: When suggesting corrections, cite relevant examples using this format:
As shown in `examples/bad_queries.promql` (lines 91-97):
❌ BAD: `avg(http_request_duration_seconds{quantile="0.95"})`
✅ GOOD: Use histogram_quantile() with histogram buckets
Citation sources:
assets/good_queries.promql- for well-formed patternsassets/optimization_examples.promql- for before/after comparisonsassets/bad_queries.promql- for showing what to avoidreferences/best_practices.md- for detailed explanationsreferences/anti_patterns.md- for anti-pattern deep dives
Citation Format: file_path (lines X-Y) with the relevant code snippet quoted
Step 7: Let User Plan/Refine
Give the user control:
- Ask if they want to modify the query
- Offer to help rewrite it for better performance
- Provide multiple alternatives if applicable
- Explain trade-offs between different approaches
Interactive Dialogue
Claude: “I’ve validated your query. It’s syntactically correct, but I notice it queries http_requests_total without any label filters. This could match thousands of time series. What specific service or endpoint are you trying to monitor?”
User: [provides intent]
Claude: “Great! Based on that, here’s an optimized version: rate(http_requests_total{job="api-service", path="/users"}[5m]). This calculates the per-second rate of requests to the /users endpoint over the last 5 minutes. Does this match what you need?”
User: [confirms or asks for changes]
Claude: [provides refined query or alternatives]
Anti-Patterns
NEVER accept “syntactically valid” as “production-ready”
WHY: A query can parse without errors but produce wildly wrong results. Syntax validation only confirms the query can be evaluated; semantic validation confirms it measures what you intend.
BAD: Stop after validate_syntax.py returns success.
GOOD: Complete all 7 workflow steps, including intent comparison (Step 5) and optimization review (Step 6).
NEVER ignore high-cardinality warnings for dashboard queries
WHY: A query missing label filters may work in development against a small data set but fan out to thousands of series in production, causing dashboard timeouts.
BAD: Dismiss the high-cardinality warning with “it works fine locally”.
GOOD: Add job=, namespace=, or service= label filters before merging.
NEVER validate a query without understanding its metric type
WHY: Using rate() on a gauge or avg() on a histogram quantile produces results that are syntactically valid but statistically wrong. Metric type must be confirmed before the query pattern is evaluated.
BAD: Validate and optimize purely by pattern-matching without confirming counter vs gauge.
GOOD: Step 4 explicitly asks “Is this a counter, gauge, histogram, or summary?” before recommending changes.
NEVER skip the for clause check on alert expressions
WHY: An alert expression without a for clause fires on a single evaluation, causing alert storms from transient spikes. The validator must flag missing for on all alerting rules.
BAD: Accept expr: error_rate > 0.05 as complete for an alerting rule.
GOOD: Flag absence of for and suggest a minimum for: 2m to reduce false positives.
NEVER apply rate() to a gauge or leave a counter raw
WHY: A counter only ever increases, so its raw value or sum says little, while rate() on a gauge reads normal drops as counter resets. Both give plausible but wrong numbers (see references/anti_patterns.md).
BAD: sum(http_requests_total) or rate(node_memory_MemAvailable_bytes[5m]).
GOOD: sum(rate(http_requests_total{job="api"}[5m])) for counters, and the gauge used directly or with avg_over_time().
NEVER average pre-calculated quantiles
WHY: The mean of per-instance quantiles is not the quantile of the combined population, so the latency figure is misleading. The best practices checker flags this.
BAD: avg(http_request_duration_seconds{quantile="0.95"})
GOOD: histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
NEVER write a rate() or increase() selector without a range vector
WHY: rate() and increase() need a range vector, so a bare instant selector is a syntax error. The syntax script catches it, but the intent behind the window still has to be confirmed with the user.
BAD: rate(http_requests_total)
GOOD: rate(http_requests_total{job="api"}[5m])
NEVER use a wildcard regex matcher where an exact matcher will do
WHY: Matchers such as job=~".*" match every series and are slower than equality matchers. The cardinality check flags unbounded selectors for this reason.
BAD: http_requests_total{job=~".*"}
GOOD: http_requests_total{job="api-service"}
NEVER run a long subquery over a wide window without a recording rule
WHY: A subquery spanning many days re-evaluates the inner expression at every step, which is slow and can time out. Precompute it instead.
BAD: max_over_time(rate(http_requests_total[5m])[95d:1m])
GOOD: Record rate(http_requests_total[5m]) as a recording rule, then query max_over_time(<recorded_series>[7d]).
NEVER present a heuristic warning as a certain defect
WHY: Metric type and cardinality are inferred from naming conventions, so non-standard names and recording-rule series can trigger false positives. Stating them as fact erodes trust in the review.
BAD: “Your query is wrong because the script says foo_bytes is a gauge.”
GOOD: “The script inferred a gauge from the _bytes suffix. Please confirm the type before I recommend changes.”
Known Limitations
Metric Type Detection
Metric types are inferred from naming conventions (e.g., _total, _bytes). Non-standard names may be misclassified: ask the user to confirm when uncertain.
High Cardinality Detection
The scripts flag metrics without label selectors, but recording rule metrics (e.g., job:http_requests:rate5m) and low-cardinality cases are legitimate without filters. Users can safely ignore the warning when they know their cardinality is manageable.
Semantic Validation
Scripts cannot verify whether metrics exist or whether label values are valid in any specific Prometheus deployment. For production use, test queries against an actual Prometheus instance.
Script Detection Coverage
The scripts detect common anti-patterns but cannot catch business logic errors, context-specific optimizations (e.g., based on scrape interval or retention), or custom function behavior from extensions.
Validation Tools
Run both scripts from the skill directory and read the JSON on standard output:
./scripts/validate_syntax.py 'rate(http_requests_total{job="api"}[5m])'
./scripts/check_best_practices.py 'avg(http_request_duration_seconds{quantile="0.95"})'
The skill uses two main Python scripts:
- validate_syntax.py: Pure syntax checking using regex patterns
- check_best_practices.py: Semantic and performance analysis
Both scripts output JSON for programmatic parsing and human-readable messages for display.
References
- Best Practices Guide: comprehensive PromQL best practices covering cardinality, function selection, and alerting rule design
- Anti-Patterns Reference: detailed anti-pattern explanations with root causes and correct alternatives
- Good Query Examples: well-written query patterns to reference when suggesting corrections
- Bad Query Examples: common mistakes with corrections; cite these when showing users what to avoid
- Optimization Examples: before/after optimization comparisons for performance improvements
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
No eval scenarios available.
PromQL Anti-Patterns
Comprehensive guide to common mistakes, anti-patterns, and pitfalls in PromQL queries.
Table of Contents
- High Cardinality Issues
- Incorrect Function Usage
- Performance Anti-Patterns
- Mathematical Errors
- Label Matching Problems
- Aggregation Mistakes
- Time Range Issues
- Histogram and Summary Misuse
High Cardinality Issues
Anti-Pattern 1: Unbounded Metric Selectors
Problem: Querying metrics without any label filters matches all time series, causing high cardinality.
# ❌ BAD: No filters - could match thousands of series
http_requests_total
# ❌ BAD: Empty label matcher
http_requests_total{}
# ✅ GOOD: Specific label filters
http_requests_total{job="api-service", environment="production"}
Impact:
- Query times: seconds to minutes instead of milliseconds
- High memory usage on Prometheus
- Risk of query timeouts
- Increased load on Prometheus server
Detection: Look for metric names without {...} selectors or with empty {}.
Anti-Pattern 2: High-Cardinality Labels in Queries
Problem: Querying on labels with thousands of unique values.
# ❌ BAD: User ID has millions of unique values
http_requests_total{user_id="12345"}
# ✅ GOOD: Use low-cardinality labels
http_requests_total{job="api", endpoint="/users"}
High-cardinality labels to avoid:
- User IDs, customer IDs
- Request IDs, trace IDs
- Timestamps
- UUIDs
- Email addresses, IP addresses (unless aggregated)
- Full URLs (use path patterns instead)
Low-cardinality labels (safe to use):
- Job name
- Instance name
- Service name
- Environment (prod, staging, dev)
- Status codes
- HTTP methods
- Endpoint paths (grouped)
Anti-Pattern 3: Wildcard Regex Without Constraints
Problem: Overly broad regex patterns match too many series.
# ❌ BAD: Matches everything
http_requests_total{path=~".*"}
# ❌ BAD: Very broad pattern
http_requests_total{instance=~".*-prod-.*"}
# ✅ GOOD: Specific pattern with other filters
http_requests_total{
job="api",
instance=~"api-prod-[0-9]+",
datacenter="us-east-1"
}
Incorrect Function Usage
Anti-Pattern 4: Missing rate() on Counters
Problem: Using counter metrics without rate() or increase() gives meaningless values.
# ❌ BAD: Raw counter value (always increasing, not useful)
http_requests_total{job="api"}
# ❌ BAD: Aggregating raw counters
sum(http_requests_total)
# ✅ GOOD: Use rate() for per-second rate
rate(http_requests_total{job="api"}[5m])
# ✅ GOOD: Use increase() for total increase
increase(http_requests_total{job="api"}[1h])
Why it’s wrong: Counters only increase (or reset). The raw value shows total count since process start, not current rate.
Anti-Pattern 5: Using rate() on Gauges
Problem: rate(), irate(), and increase() assume monotonically increasing values. Gauges go up and down.
# ❌ BAD: rate() on gauge (memory can go up or down)
rate(node_memory_usage_bytes[5m])
# ❌ BAD: irate() on gauge
irate(cpu_temperature_celsius[5m])
# ✅ GOOD: Use gauge directly
node_memory_usage_bytes
# ✅ GOOD: Or use avg_over_time for smoothing
avg_over_time(node_memory_usage_bytes[5m])
# ✅ GOOD: Use delta() if you need change over time
delta(cpu_temperature_celsius[5m])
How to identify:
- Counters typically end with:
_total,_count,_sum,_bucket - Gauges typically indicate current state:
_bytes,_usage,_percent,_celsius
Anti-Pattern 6: rate() Without Range Vector
Problem: rate(), irate(), increase() require a time range.
# ❌ BAD: Missing range vector
rate(http_requests_total)
# ❌ BAD: Missing range vector
increase(requests_total{job="api"})
# ✅ GOOD: Include time range
rate(http_requests_total[5m])
# ✅ GOOD: Include range for increase
increase(requests_total{job="api"}[1h])
Error message: "parse error: expected type range vector in call to function, got instant vector"
Performance Anti-Patterns
Anti-Pattern 7: Excessive Subquery Time Ranges
Problem: Subqueries over very long time ranges process millions of samples.
# ❌ BAD: 95-day subquery (extremely slow, may timeout)
max_over_time(rate(http_requests_total[5m])[95d:1m])
# ❌ BAD: Long range with high resolution
avg_over_time(metric[30d:10s])
# ✅ GOOD: Reasonable time range
max_over_time(rate(http_requests_total[5m])[7d:5m])
# ✅ BETTER: Use recording rules for long-term analysis
# Create recording rule:
# - record: :http_requests:rate5m
# expr: rate(http_requests_total[5m])
# Then query:
max_over_time(:http_requests:rate5m[30d:1h])
Impact:
- Query timeouts
- Excessive memory usage (GBs)
- Prometheus server overload
- Minutes to execute instead of seconds
Anti-Pattern 8: Regex Instead of Exact Match
Problem: Using regex (=~) when exact match (=) would work.
# ❌ BAD: Regex for exact match (slower)
http_requests_total{status=~"200"}
# ❌ BAD: Regex that's actually exact
http_requests_total{job=~"api-service"}
# ✅ GOOD: Exact match (faster index lookup)
http_requests_total{status="200"}
# ✅ GOOD: Exact match
http_requests_total{job="api-service"}
Performance difference: Exact matches can be 5-10x faster due to index lookups vs pattern matching.
When regex IS appropriate:
# ✅ GOOD: Multiple alternatives
http_requests_total{status=~"200|201|204"}
# ✅ GOOD: Pattern matching
http_requests_total{path=~"/api/v[0-9]+/.*"}
# ✅ GOOD: Exclusions
http_requests_total{path!~"/health|/metrics"}
Anti-Pattern 9: Not Using Recording Rules for Complex Queries
Problem: Running expensive queries repeatedly in multiple dashboards/alerts.
# ❌ BAD: Complex query used in 10 dashboards, evaluated 100 times/minute
sum by (job, instance) (
rate(http_request_duration_seconds_sum{job="api"}[5m])
) /
sum by (job, instance) (
rate(http_request_duration_seconds_count{job="api"}[5m])
)
# ✅ GOOD: Create recording rule (evaluated once per cycle)
# prometheus.yml:
# - record: job_instance:http_request_duration_seconds:mean5m
# expr: |
# sum by (job, instance) (
# rate(http_request_duration_seconds_sum{job="api"}[5m])
# ) /
# sum by (job, instance) (
# rate(http_request_duration_seconds_count{job="api"}[5m])
# )
# Then use pre-computed metric:
job_instance:http_request_duration_seconds:mean5m
When to use recording rules:
- Query is complex (multiple functions, aggregations)
- Query is used frequently (dashboards, multiple alerts)
- Query is slow (>1 second)
- Query uses subqueries
Anti-Pattern 10: Filter After Aggregation
Problem: Filtering after expensive aggregation processes unnecessary data.
# ❌ BAD: Aggregates all jobs first, then filters
sum(rate(http_requests_total[5m])) and {job="api"}
# ❌ BAD: Processes all data before filtering
sum by (job) (rate(http_requests_total[5m])) == {job="api"}
# ✅ GOOD: Filter first, then aggregate
sum(rate(http_requests_total{job="api"}[5m]))
# ✅ GOOD: Specific filters reduce data early
sum by (path) (rate(http_requests_total{job="api", status="200"}[5m]))
Performance impact: 10-100x slower when filtering after aggregation.
Mathematical Errors
Anti-Pattern 11: Averaging Pre-Calculated Quantiles
Problem: Averaging quantiles across instances is mathematically invalid.
# ❌ BAD: Mathematically incorrect!
avg(http_request_duration_seconds{quantile="0.95"})
# ❌ BAD: Summing quantiles is also wrong
sum(response_time_seconds{quantile="0.99"})
# ✅ GOOD: Calculate quantile from histogram buckets
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# ✅ GOOD: Calculate average from _sum and _count
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])
Why it’s wrong: Quantiles are non-additive. The average of two 95th percentiles is NOT the overall 95th percentile.
Solution: Use histograms instead of summaries when you need aggregation.
Anti-Pattern 12: Division with Mismatched Labels
Problem: Dividing metrics with different label sets gives unexpected results.
# ❌ BAD: Labels don't match (no instance on right side)
rate(http_requests_total{job="api", instance="host1"}[5m])
/
rate(http_requests_total{job="api"}[5m])
# Result: No data (label mismatch)
# ✅ GOOD: Ensure both sides have same label filters
rate(http_requests_total{job="api", status="500"}[5m])
/
rate(http_requests_total{job="api"}[5m])
# ✅ GOOD: Use aggregation to match label dimensions
sum(rate(http_requests_total{status="500"}[5m]))
/
sum(rate(http_requests_total[5m]))
Label Matching Problems
Anti-Pattern 13: Incorrect offset Modifier Usage
Problem: Using offset incorrectly or misunderstanding its placement.
# ✅ CORRECT: offset after range vector selector
http_requests_total[5m] offset 1h
# ✅ CORRECT: offset with instant vector
http_requests_total offset 1h
# ✅ CORRECT: offset inside rate() function
rate(http_requests_total[5m] offset 1h)
# ❌ BAD: offset between metric name and range (invalid syntax)
http_requests_total offset 1h [5m]
Note: The offset modifier shifts the time range back by the specified duration. It comes AFTER the selector (including range vector bracket if present).
Anti-Pattern 14: Multiple OR for Same Label
Problem: Using multiple OR operations instead of regex alternation.
# ❌ BAD: Multiple queries combined with OR
http_requests_total{job="api"}
or
http_requests_total{job="web"}
or
http_requests_total{job="worker"}
# ✅ GOOD: Single regex with alternatives
http_requests_total{job=~"api|web|worker"}
# ✅ GOOD: With aggregation
sum by (job) (rate(http_requests_total{job=~"api|web|worker"}[5m]))
Performance: Single regex is 3-5x faster than multiple ORs.
Aggregation Mistakes
Anti-Pattern 15: Aggregation Without by() or without()
Problem: Unclear what labels remain after aggregation.
# ❌ BAD: What labels are in the result?
sum(rate(http_requests_total[5m]))
# ❌ BAD: Unclear aggregation
avg(node_memory_usage_bytes)
# ✅ GOOD: Explicit grouping
sum by (job, instance) (rate(http_requests_total[5m]))
# ✅ GOOD: Explicit label dropping
sum without (pod, container) (rate(http_requests_total[5m]))
Anti-Pattern 16: Aggregating Before Division
Problem: Order of operations affects results for ratios.
# ❌ BAD: Sum first, then divide (wrong denominator)
sum(rate(http_request_duration_seconds_sum[5m]))
/
sum(rate(http_request_duration_seconds_count[5m]))
# This gives overall average, not average per instance
# ✅ GOOD: Divide first, then aggregate (if you want per-instance avg)
sum(
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])
)
# Note: Both may be valid depending on your goal!
# Be explicit about what you're calculating.
Time Range Issues
Anti-Pattern 17: irate() with Long Ranges
Problem: irate() only uses the last two samples, making longer ranges wasteful.
# ❌ BAD: irate over 1 hour (only uses last 2 samples!)
irate(http_requests_total[1h])
# ❌ BAD: irate over 10 minutes (still only 2 samples)
irate(http_requests_total[10m])
# ✅ GOOD: Use rate() for longer ranges
rate(http_requests_total[1h])
# ✅ GOOD: Use irate() with short range
irate(http_requests_total[2m])
When to use irate():
- High-frequency monitoring (per-second spikes)
- Short time ranges (2-5 minutes)
- When you want instant rate, not average
When to use rate():
- Most cases
- Alerting (more stable)
- Longer time ranges (>5 minutes)
- When you want average rate over period
Anti-Pattern 18: rate() Range Too Short
Problem: rate() range shorter than 4x scrape interval gives inaccurate results.
# ❌ BAD: 30s range with 15s scrape interval (only 2 samples)
rate(http_requests_total[30s])
# ❌ BAD: 1m range might not have enough samples
rate(http_requests_total[1m])
# ✅ GOOD: At least 4x scrape interval (for 15s scrape: 1m minimum)
rate(http_requests_total[2m])
# ✅ GOOD: 5m is a common, safe choice
rate(http_requests_total[5m])
Rule: rate_range >= 4 * scrape_interval
Histogram and Summary Misuse
Anti-Pattern 19: histogram_quantile Without rate()
Problem: histogram_quantile needs rate() on bucket metrics.
# ❌ BAD: Missing rate() (uses raw bucket counts)
histogram_quantile(0.95,
sum by (le) (http_request_duration_seconds_bucket)
)
# ✅ GOOD: Include rate()
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
Anti-Pattern 20: histogram_quantile Without ‘le’ Label
Problem: histogram_quantile requires the ‘le’ (less than or equal) label.
# ❌ BAD: Missing 'le' in aggregation
histogram_quantile(0.95,
sum by (job) (rate(http_request_duration_seconds_bucket[5m]))
)
# ✅ GOOD: Include 'le' in by() clause
histogram_quantile(0.95,
sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
)
# ✅ GOOD: Remove other labels but keep 'le'
histogram_quantile(0.95,
sum without (instance, pod) (rate(http_request_duration_seconds_bucket[5m]))
)
Anti-Pattern 21: Using Summaries When You Need Aggregation
Problem: Summary quantiles cannot be aggregated across instances.
# ❌ BAD: Cannot meaningfully aggregate summary quantiles
avg(http_request_duration_seconds{quantile="0.95"})
# ✅ SOLUTION: Use histograms instead of summaries
# Histograms allow aggregation:
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
When to use each:
- Histogram: Need to aggregate across instances, calculate multiple quantiles
- Summary: Per-instance quantiles, lower memory overhead, don’t need aggregation
Additional Anti-Patterns
Anti-Pattern 22: Nested Redundant Functions
Problem: Applying the same function twice or unnecessary nesting.
# ❌ BAD: Double rate (doesn't make sense)
rate(rate(http_requests_total[5m])[10m])
# ❌ BAD: Unnecessary nesting
avg(avg_over_time(metric[5m]))
# ✅ GOOD: Single function
rate(http_requests_total[5m])
# ✅ GOOD: Single aggregation
avg_over_time(metric[5m])
Anti-Pattern 23: Forgetting group_left/group_right in Joins
Problem: One-to-many joins require group_left or group_right.
# ❌ BAD: Many-to-one join without group_left
rate(http_requests_total[5m])
* on (job, instance)
service_info
# Error: "multiple matches for labels"
# ✅ GOOD: Use group_left to include labels from right side
rate(http_requests_total[5m])
* on (job, instance) group_left (version, commit)
service_info
Summary Checklist
Before running your query, check:
- All metrics have specific label filters
- Using rate() on counters, not on gauges
- Using exact match (=) instead of regex (=~) when possible
- rate() range is at least 2-4 minutes
- irate() range is 2-5 minutes maximum
- Aggregations have by() or without() clauses
- Not averaging pre-calculated quantiles
- histogram_quantile includes rate() and ‘le’ label
- Subquery ranges are reasonable (<7 days typically)
- Complex/frequent queries use recording rules
- Not using high-cardinality labels
Resources
PromQL Best Practices
Comprehensive guide to writing efficient, correct, and maintainable PromQL queries.
Table of Contents
- Metric Types and Functions
- Label Filtering
- Aggregations
- Time Ranges
- Performance Optimization
- Recording Rules
- Histograms and Summaries
- Alerting Queries
- Common Patterns
Metric Types and Functions
Counters
What they are: Metrics that only increase (or reset to zero). Examples: http_requests_total, errors_count.
Best practices:
- ✅ Always use
rate()orincrease()with counters - ✅ Use
rate()for per-second rates:rate(http_requests_total[5m]) - ✅ Use
increase()for total increase:increase(http_requests_total[1h]) - ❌ Never use raw counter values (they always increase, not useful)
- ❌ Never use
rate()orincrease()without a range vector
Naming convention: Counters typically end with _total, _count, _sum, or _bucket.
Examples:
# Good: Calculate requests per second
rate(http_requests_total{job="api"}[5m])
# Good: Total requests in last hour
increase(http_requests_total{job="api"}[1h])
# Bad: Raw counter value
http_requests_total{job="api"}
Gauges
What they are: Metrics that can go up and down. Examples: memory_usage_bytes, temperature_celsius.
Best practices:
- ✅ Use gauge values directly
- ✅ Use
avg_over_time(),max_over_time(),min_over_time()for time windows - ✅ Can use
delta()for change over time (but not common) - ❌ Never use
rate(),irate(), orincrease()on gauges - ❌ These functions assume monotonically increasing values
Examples:
# Good: Current memory usage
node_memory_usage_bytes{instance="prod-1"}
# Good: Average over time
avg_over_time(node_memory_usage_bytes{instance="prod-1"}[5m])
# Good: Maximum in last hour
max_over_time(node_cpu_percent{instance="prod-1"}[1h])
# Bad: Rate on gauge
rate(memory_usage_bytes[5m])
Histograms
What they are: Multiple time series representing bucketed observations. Metrics end with _bucket, _sum, _count.
Best practices:
- ✅ Use
histogram_quantile()to calculate quantiles - ✅ Always include
lelabel inby()clause - ✅ Use
rate()on bucket metrics - ✅ Aggregate before calculating quantiles
- ❌ Never average pre-calculated quantiles
Examples:
# Good: Calculate 95th percentile latency
histogram_quantile(0.95,
sum by (job, le) (
rate(http_request_duration_seconds_bucket{job="api"}[5m])
)
)
# Good: Calculate average from histogram
rate(http_request_duration_seconds_sum{job="api"}[5m])
/
rate(http_request_duration_seconds_count{job="api"}[5m])
# Bad: Missing rate()
histogram_quantile(0.95, sum by (le) (http_request_duration_seconds_bucket))
# Bad: Missing 'le' in aggregation
histogram_quantile(0.95, sum by (job) (rate(http_request_duration_seconds_bucket[5m])))
Summaries
What they are: Pre-calculated quantiles with _sum and _count. Includes labels like quantile="0.95".
Best practices:
- ✅ Use
_sumand_countto calculate averages - ❌ Never average or aggregate pre-calculated quantiles
- ❌ Quantiles from summaries cannot be aggregated across instances
Examples:
# Good: Calculate average from summary
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])
# Bad: Averaging quantiles (mathematically invalid!)
avg(http_request_duration_seconds{quantile="0.95"})
Label Filtering
Always Use Specific Label Filters
Why: Reduces cardinality, improves query performance, and makes intent clear.
# Bad: No filters
http_requests_total
# Good: Specific filters
http_requests_total{job="api-service", environment="production"}
# Good: Multiple filters for precision
http_requests_total{
job="api-service",
environment="production",
datacenter="us-east-1",
instance="prod-api-1"
}
Use Exact Matches Over Regex When Possible
Why: Exact matches are faster (index lookups) vs regex (pattern matching).
# Bad: Regex for exact match
http_requests_total{status=~"200"}
# Good: Exact match
http_requests_total{status="200"}
# Regex is fine when you need it:
http_requests_total{status=~"2[0-9]{2}"} # All 2xx status codes
Efficient Regex Patterns
# Bad: Multiple OR queries
sum(http_requests_total{path="/api/users"})
or
sum(http_requests_total{path="/api/products"})
or
sum(http_requests_total{path="/api/orders"})
# Good: Single regex with alternation
sum by (path) (
http_requests_total{path=~"/api/(users|products|orders)"}
)
# Good: Negative regex for exclusions
http_requests_total{path!~"/health|/metrics"}
Label Matcher Operators
=: Equal to!=: Not equal to=~: Regex match (fully anchored)!~: Regex does not match
Aggregations
Always Use by() or without() Clauses
Why: Makes output labels explicit and prevents confusion.
# Unclear: What labels will remain?
sum(rate(http_requests_total[5m]))
# Clear: Group by these labels
sum by (job, instance) (rate(http_requests_total[5m]))
# Clear: Remove only these labels
sum without (pod, container) (rate(http_requests_total[5m]))
Use without() for High-Cardinality Labels
Why: More maintainable when you want to keep many labels.
# Verbose: List all labels to keep
sum by (job, instance, environment, datacenter, region, cluster, zone) (metric)
# Better: Drop only the high-cardinality labels
sum without (pod, container, node) (metric)
Common Aggregation Operators
sum: Total across seriesavg: Average valuemin: Minimum valuemax: Maximum valuecount: Count of seriesstddev: Standard deviationstdvar: Standard variancetopk(N, ...): Top N seriesbottomk(N, ...): Bottom N seriesquantile(φ, ...): φ-quantile (0 ≤ φ ≤ 1)
Aggregation Examples
# Sum request rate per service
sum by (service) (rate(http_requests_total[5m]))
# Average CPU across all cores per node
avg by (instance) (rate(node_cpu_seconds_total[5m]))
# Top 10 pods by memory usage
topk(10, container_memory_usage_bytes)
# Count running instances per job
count by (job) (up == 1)
Time Ranges
rate() Range Selection
Rule of thumb: Use at least 4x your scrape interval.
- Typical scrape interval: 15s
- Minimum
rate()range:[1m](preferably[2m])
# Bad: Too short (less than 4x scrape interval)
rate(http_requests_total[30s])
# Good: At least 2 minutes
rate(http_requests_total[2m])
# Common: 5 minutes (good balance of responsiveness and stability)
rate(http_requests_total[5m])
# Longer ranges: More stable, less sensitive to spikes
rate(http_requests_total[15m])
irate() vs rate()
irate(): Instant rate, only uses last two samples.
- ✅ Use for high-frequency, short-range monitoring
- ✅ Good for rapidly changing metrics
- ✅ Range:
[2m]to[5m]typically - ❌ Don’t use for long ranges (wasted range)
rate(): Average rate over entire range.
- ✅ Use for most cases
- ✅ More stable and accurate for longer ranges
- ✅ Better for alerting (less noisy)
# Good: irate with short range
irate(http_requests_total[2m])
# Good: rate for longer range
rate(http_requests_total[5m])
# Bad: irate with long range (only uses last 2 samples anyway!)
irate(http_requests_total[1h])
Subqueries
Syntax: query[range:resolution]
Use sparingly: Subqueries can be very expensive.
# Calculate max rate over 30 minutes with 1-minute resolution
max_over_time(
rate(http_requests_total[5m])[30m:1m]
)
# Bad: Excessive range
max_over_time(
rate(http_requests_total[5m])[95d:1m]
) # Processes millions of samples!
# Better: Use recording rules for long ranges
Performance Optimization
1. Filter Early, Aggregate Late
# Good: Filter before expensive operations
sum(rate(http_requests_total{job="api", status="200"}[5m]))
# Bad: Filter after aggregation (processes more data)
sum(rate(http_requests_total[5m])) and {job="api", status="200"}
2. Use topk/bottomk to Limit Results
# Instead of processing all series:
sum by (pod) (rate(container_cpu_usage[5m]))
# Limit to top 10 in query:
topk(10, sum by (pod) (rate(container_cpu_usage[5m])))
3. Avoid High-Cardinality Labels
- User IDs, request IDs, timestamps as labels = BAD
- Job, instance, path, status code = OK
- Keep label cardinality under 10-100 unique values when possible
4. Use Recording Rules for Complex Queries
See Recording Rules section below.
5. Minimize Regex Usage
# Slower: Regex match
{label=~"value"}
# Faster: Exact match
{label="value"}
6. Share Common Subexpressions
# Bad: Same rate calculated twice
rate(metric[5m]) / rate(metric[5m] offset 1h)
# Can't be optimized in PromQL directly, but use recording rules:
# - record: metric:rate5m
# expr: rate(metric[5m])
# Then:
metric:rate5m / (metric:rate5m offset 1h)
Recording Rules
Purpose: Pre-compute frequently-used or expensive queries.
Benefits:
- Faster dashboard loads
- Lower query latency
- Reduced Prometheus CPU usage
- Easier to maintain complex expressions
When to use:
- Query runs frequently (multiple dashboards, alerts)
- Query is computationally expensive
- Query spans long time ranges (subqueries)
- Query is complex (multiple aggregations, joins)
Naming convention:
level:metric:operations
Examples:
job:http_requests:rate5minstance:node_cpu:rate1mjob_instance:request_latency_seconds:mean5m
Configuration example:
groups:
- name: example_recording_rules
interval: 30s
rules:
# Basic rate recording
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
# Error rate recording
- record: job:http_requests:error_rate5m
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
# Average latency recording
- record: job:http_request_latency_seconds:mean5m
expr: |
sum by (job) (rate(http_request_duration_seconds_sum[5m]))
/
sum by (job) (rate(http_request_duration_seconds_count[5m]))
Histograms and Summaries
Histogram Best Practices
# Calculate quantile
histogram_quantile(0.95,
sum by (le, job) (
rate(http_request_duration_seconds_bucket{job="api"}[5m])
)
)
# Always include 'le' in aggregation
sum by (job, le) (...) # ✅ Correct
sum by (job) (...) # ❌ Wrong - missing 'le'
# Use rate() on bucket metrics
rate(http_request_duration_seconds_bucket[5m]) # ✅ Correct
http_request_duration_seconds_bucket # ❌ Wrong - missing rate()
Calculate Average from Histogram
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])
Count Observations
rate(http_request_duration_seconds_count[5m])
Native Histograms (Prometheus 2.40+/3.0)
Native histograms are a newer histogram format introduced in Prometheus 2.40 and made stable in 3.0. They offer significant storage and query efficiency improvements over classic histograms.
Key Differences from Classic Histograms
| Classic Histograms | Native Histograms |
|---|---|
Separate _bucket, _sum, _count time series |
Single time series containing all data |
| Fixed bucket boundaries defined at instrumentation | Dynamic bucket resolution |
Requires _bucket suffix in queries |
No _bucket suffix needed |
Always need le label in aggregation |
No le label manipulation |
Native Histogram Query Syntax
# Classic histogram (old way)
histogram_quantile(0.9, sum by (job, le) (rate(http_request_duration_seconds_bucket[10m])))
# Native histogram (simpler - no _bucket suffix, no 'le' label needed)
histogram_quantile(0.9, sum by (job) (rate(http_request_duration_seconds[10m])))
Native Histogram Functions
Prometheus provides special functions for native histograms:
# Calculate average from native histogram
histogram_avg(rate(http_request_duration_seconds[5m]))
# Calculate standard deviation
histogram_stddev(rate(http_request_duration_seconds[5m]))
# Calculate standard variance
histogram_stdvar(rate(http_request_duration_seconds[5m]))
# Get observation count
histogram_count(rate(http_request_duration_seconds[5m]))
# Get sum of observations
histogram_sum(rate(http_request_duration_seconds[5m]))
# Get fraction of observations in a range
histogram_fraction(0.1, 0.5, rate(http_request_duration_seconds[5m]))
Best Practices for Native Histograms
-
Still use
rate()with native histograms - The histogram functions work with rate-aggregated data# ✅ Correct histogram_avg(rate(http_request_duration_seconds[5m])) # ❌ Wrong - missing rate() histogram_avg(http_request_duration_seconds) -
Simpler aggregation - No need for
lelabel inby()clause# Classic histogram - need 'le' histogram_quantile(0.95, sum by (job, le) (rate(metric_bucket[5m]))) # Native histogram - no 'le' needed histogram_quantile(0.95, sum by (job) (rate(metric[5m]))) -
Enable native histograms in Prometheus - Requires configuration:
# prometheus.yml global: scrape_native_histograms: true -
Check if metrics are native or classic - Query the metric directly to see its format in the response
When to Use Native Histograms
- ✅ New projects starting with Prometheus 2.40+
- ✅ High-cardinality histogram data (storage efficiency)
- ✅ When you need many quantile calculations (query efficiency)
- ❌ Legacy systems that don’t support native histograms
- ❌ When you need exact bucket boundaries for compliance
Alerting Queries
Keep Alert Expressions Simple
# Bad: Complex alert expression
alert: HighErrorRate
expr: |
(
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
) > 0.05
# Better: Use recording rule, simple alert
# Recording rule:
- record: job:http_requests:error_rate5m
expr: ...
# Alert:
alert: HighErrorRate
expr: job:http_requests:error_rate5m > 0.05
Use for Clause to Avoid Flapping
- alert: HighMemoryUsage
expr: node_memory_usage_percent > 90
for: 5m # Must be true for 5 minutes
annotations:
summary: "High memory usage on {{ $labels.instance }}"
Alert on Rate of Change
# Alert if request rate drops suddenly
(
rate(http_requests_total[5m])
/
rate(http_requests_total[5m] offset 1h)
) < 0.5 # Less than 50% of rate 1 hour ago
Common Patterns
Error Rate Calculation
# Error rate as percentage
(
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
) * 100
Success Rate
# Success rate as percentage
(
sum(rate(http_requests_total{status=~"2.."}[5m]))
/
sum(rate(http_requests_total[5m]))
) * 100
Percentage Calculation
# Memory usage percentage
(
node_memory_usage_bytes
/
node_memory_total_bytes
) * 100
Comparing to Historical Baseline
# Compare current to 1 day ago
rate(http_requests_total[5m])
/
rate(http_requests_total[5m] offset 1d)
Detect Sudden Spikes
# Alert if current rate > 2x the max rate in last hour
rate(metric[5m])
>
max_over_time(rate(metric[5m])[1h:]) * 2
Absent Metrics (Alerting)
# Alert if metric disappears
absent(up{job="critical-service"})
# Alert if metric was present but now gone
absent_over_time(up{job="critical-service"}[5m])
Joining Metrics
# Add labels from info metric to other metrics
rate(http_requests_total[5m])
* on (job, instance) group_left (version, commit)
service_info
Quick Reference
| Pattern | Use Case | Example |
|---|---|---|
rate(counter[5m]) |
Per-second rate of counter | rate(http_requests_total[5m]) |
increase(counter[1h]) |
Total increase in counter | increase(requests_total[1h]) |
gauge |
Current value | node_memory_usage_bytes |
avg_over_time(gauge[5m]) |
Average gauge over time | avg_over_time(cpu_percent[5m]) |
histogram_quantile(0.95, ...) |
Calculate percentile | See histogram section |
sum by (label) (...) |
Aggregate by labels | sum by (job) (rate(metric[5m])) |
topk(N, ...) |
Top N series | topk(10, metric) |
absent(metric) |
Check if metric missing | absent(up{job="api"}) |
metric offset 1h |
Historical comparison | rate(metric[5m] offset 1h) |