
Promql Validator
- 381 installs
- 286 repo stars
- Updated July 26, 2026
- akin-ozer/cc-devops-skills
promql-validator is an agent skill that lints PromQL queries before alert rules or Grafana dashboard JSON deploy for developers who need to catch invalid functions, bad matchers, and cardinality traps early.
About
promql-validator is a cc-devops-skills agent skill from akin-ozer that lints PromQL expressions before developers apply alert rules or import dashboard JSON into Grafana staging or production environments. The workflow catches invalid PromQL functions, malformed label matchers, and cardinality traps that would otherwise surface as silent query failures, empty panels, or runaway metric series after deployment. Agents invoke promql-validator when reviewing Grafana alert YAML, Prometheus recording rules, or dashboard panel queries so platform teams fix syntax and performance issues in pull requests rather than during incidents. promql-validator fits observability engineers and SREs who maintain Prometheus-compatible monitoring stacks and want automated guardrails before changes reach clusters. The skill complements Grafana and Prometheus CI pipelines by treating PromQL as reviewable infrastructure code rather than ad hoc console experimentation. Developers reach for promql-validator whenever alert or dashboard diffs need static validation before merge to observability repos.
- Catches syntax and semantic PromQL errors early
- Prevents empty or expensive alert evaluations
- Validates label selectors against conventions
- Supports safer Grafana and Alertmanager changes
- Reduces on-call surprises from broken queries
Promql Validator by the numbers
- 381 all-time installs (skills.sh)
- Ranked #309 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/akin-ozer/cc-devops-skills --skill promql-validatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 381 |
|---|---|
| repo stars | ★ 286 |
| Last updated | July 26, 2026 |
| Repository | akin-ozer/cc-devops-skills ↗ |
How do you lint PromQL before Grafana deploys?
Lint PromQL before applying alert rules or dashboard JSON to catch invalid functions, bad label matchers, and cardinality traps in staging or prod Grafana.
Who is it for?
Platform and observability engineers who maintain Prometheus or Grafana alert rules and need PromQL validation before staging or production rollout.
Skip if: Teams without Prometheus-compatible metrics stacks or tasks limited to application unit tests unrelated to monitoring query syntax.
When should I use this skill?
A developer edits Grafana dashboards, alert rules, or recording rules and needs PromQL validated for syntax errors and cardinality risks before apply.
What you get
PromQL lint report, flagged invalid functions and matchers, cardinality warnings, and corrected query snippets ready for alert or dashboard JSON.
- PromQL lint findings
- Cardinality warnings
- Corrected query suggestions
Files
How This Skill Works
This skill performs multi-level validation and provides interactive query planning:
1. Syntax Validation: Checks for syntactically correct PromQL expressions 2. Semantic Validation: Ensures queries make logical sense (e.g., rate() on counters, not gauges) 3. Anti-Pattern Detection: Identifies common mistakes and inefficient patterns 4. Optimization Suggestions: Recommends performance improvements 5. Query Explanation: Translates PromQL to plain English 6. Interactive Planning: Helps users clarify intent and refine queries
Workflow
When a user provides a PromQL query, follow this workflow:
Working Directory Requirement
Run validation commands from the repository root so relative paths resolve correctly:
cd "$(git rev-parse --show-toplevel)"If running from another location, use absolute paths to scripts/ files.
Step 1: Validate Syntax
Run the syntax validation script to check for basic correctness:
python3 devops-skills-plugin/skills/promql-validator/scripts/validate_syntax.py "<query>"Output parsing notes:
- Exit
0: syntax valid - Exit non-zero: syntax failure; include stderr and pinpoint token/position
- Prefer quoting the smallest failing fragment, then provide corrected query
The script will check for:
- Valid metric names and label matchers
- Correct operator usage
- Proper function syntax
- Valid time durations and ranges
- Balanced brackets and quotes
- Correct use of modifiers (offset, @)
Step 2: Check Best Practices
Run the best practices checker to detect anti-patterns and optimization opportunities:
python3 devops-skills-plugin/skills/promql-validator/scripts/check_best_practices.py "<query>"Output parsing notes:
- Treat script sections as independent findings (cardinality, metric-type misuse, regex misuse, etc.)
- If script output is empty but query is complex, add a manual sanity pass and mark it as
manual-review - Preserve script wording for finding labels, then add remediation in plain English
The script will identify:
- High cardinality queries without label filters
- Inefficient regex matchers that could be exact matches
- Missing rate()/increase() on counter metrics
- rate() used on gauge metrics
- Averaging pre-calculated quantiles
- Subqueries with excessive time ranges
- irate() over long time ranges
- Opportunities to add more specific label filters
- Complex queries that should use recording rules
Step 3: Explain the Query
Parse and explain what the query does in plain English:
- What metrics are being queried
- What type of metrics they are (counter, gauge, histogram, summary)
- What functions are applied and why
- What the query calculates
- What labels will be in the output
- What the expected result structure looks like
Required Output Details (always include these explicitly):
**Output Labels**: [list labels that will be in the result, or "None (fully aggregated to scalar)"]
**Expected Result Structure**: [instant vector / range vector / scalar] with [N series / single value]Example:
**Output Labels**: job, instance
**Expected Result Structure**: Instant vector with one series per job/instance combinationLine-Number Citation Method (Required)
When citing examples/docs in recommendations, include file path + 1-based line numbers:
examples/good_queries.promql:42
docs/best_practices.md:88Rules:
- Cite the most relevant single line (or start line if multi-line snippet)
- Keep citations tight; do not cite full files
- If line numbers are unavailable, state
line number unavailableand provide file path
Step 4: Interactive Query Planning (Phase 1 - STOP AND WAIT)
Ask the user clarifying questions to verify the query matches their intent:
1. Understand the Goal: "What are you trying to monitor or measure?"
- Request rate, error rate, latency, resource usage, etc.
2. Verify Metric Type: "Is this a counter (always increasing), gauge (can go up/down), histogram, or summary?"
- This affects which functions to use
3. Clarify Time Range: "What time window do you need?"
- Instant value, rate over time, historical analysis
4. Confirm Aggregation: "Do you need to aggregate data across labels? If so, which labels?"
- by (job), by (instance), without (pod), etc.
5. Check Output Intent: "Are you using this for alerting, dashboarding, or ad-hoc analysis?"
- Affects optimization priorities
IMPORTANT: Two-Phase Dialogue
>
After presenting Steps 1-4 results (Syntax, Best Practices, Query Explanation, and Intent Questions):
>
⏸️ STOP HERE AND WAIT FOR USER RESPONSE
>
Do NOT proceed to Steps 5-7 until the user answers the clarifying questions.
This ensures the subsequent recommendations are tailored to the user's actual intent.
Step 5: Compare Intent vs Implementation (Phase 2 - After User Response)
Only proceed to this step after the user has answered the clarifying questions from Step 4.
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 bucketsCitation sources:
examples/good_queries.promql- for well-formed patternsexamples/optimization_examples.promql- for before/after comparisonsexamples/bad_queries.promql- for showing what to avoiddocs/best_practices.md- for detailed explanationsdocs/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
Key Validation Rules
Syntax Rules
1. Metric Names: Must match [a-zA-Z_:][a-zA-Z0-9_:]* or use UTF-8 quoting syntax (Prometheus 3.0+):
- Quoted form:
{"my.metric.with.dots"} - Using __name__ label:
{__name__="my.metric.with.dots"}
2. Label Matchers: = (equal), != (not equal), =~ (regex match), !~ (regex not match) 3. Time Durations: [0-9]+(ms|s|m|h|d|w|y) - e.g., 5m, 1h, 7d 4. Range Vectors: metric_name[duration] - e.g., http_requests_total[5m] 5. Offset Modifier: offset <duration> - e.g., metric_name offset 5m 6. @ Modifier: @ <timestamp> or @ start() / @ end()
Semantic Rules
1. rate() and irate(): Should only be used with counter metrics (metrics ending in _total, _count, _sum, or _bucket) 2. Counters: Should typically use rate() or increase(), not raw values 3. Gauges: Should not use rate() or increase() 4. Histograms: Use histogram_quantile() with le label and rate() on _bucket metrics 5. Summaries: Don't average quantiles; calculate from _sum and _count 6. Aggregations: Use by() or without() to control output labels
Performance Rules
1. Cardinality: Always use specific label matchers to reduce series count 2. Regex: Use = instead of =~ when possible for exact matches 3. Rate Range: Should be at least 4x the scrape interval (typically [2m] minimum) 4. irate(): Best for short ranges (<5m); use rate() for longer periods 5. Subqueries: Avoid excessive time ranges that process millions of samples 6. Recording Rules: Use for complex queries accessed frequently
Anti-Patterns to Detect
High Cardinality Issues
❌ Bad: http_requests_total{}
- Matches all time series without filtering
✅ Good: http_requests_total{job="api", instance="prod-1"}
- Specific label filters reduce cardinality
Regex Overuse
❌ Bad: http_requests_total{status=~"2.."}
- Regex is slower and less precise
✅ Good: http_requests_total{status="200"}
- Exact match is faster
Missing rate() on Counters
❌ Bad: http_requests_total
- Counter raw values are not useful (always increasing)
✅ Good: rate(http_requests_total[5m])
- Rate shows requests per second
rate() on Gauges
❌ Bad: rate(memory_usage_bytes[5m])
- Gauges measure current state, not cumulative values
✅ Good: memory_usage_bytes
- Use gauge value directly or with
avg_over_time()
Averaging Quantiles
❌ Bad: avg(http_request_duration_seconds{quantile="0.95"})
- Mathematically invalid to average pre-calculated quantiles
✅ Good: histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
- Calculate quantile from histogram buckets
Excessive Subquery Ranges
❌ Bad: rate(metric[5m])[90d:1m]
- Processes millions of samples, very slow
✅ Good: Use recording rules or limit range to necessary duration
irate() Over Long Ranges
❌ Bad: irate(metric[1h])
- irate() only looks at last two samples, range is wasted
✅ Good: rate(metric[1h]) or irate(metric[5m])
- Use rate() for longer ranges or reduce irate() range
Mixed Metric Types
❌ Bad: avg(http_request_duration_seconds{quantile="0.95"}) / rate(node_memory_usage_bytes[1h]) + sum(http_requests_total)
- Combines summary quantiles, gauge metrics, and counters in arithmetic
- Produces meaningless results
✅ Good: Keep each metric type in separate, purpose-specific queries:
- Latency:
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) - Memory:
node_memory_usage_bytes{instance="prod-1"} - Request rate:
rate(http_requests_total{job="api"}[5m])
Output Format
Provide validation results in this structure:
## PromQL Validation Results
### Syntax Check
- Status: ✅ VALID / ⚠️ WARNING / ❌ ERROR
- Issues: [list any syntax errors with line/position]
### Semantic Check
- Status: ✅ VALID / ⚠️ WARNING / ❌ ERROR
- Issues: [list any logical problems]
### Performance Analysis
- Status: ✅ OPTIMIZED / ⚠️ CAN BE IMPROVED / ❌ INEFFICIENT
- Issues: [list optimization opportunities]
- Suggestions: [specific improvements]
### Query Explanation
Your query: `<query>`
This query does:
- [Plain English explanation]
- Metrics: [list metrics and their types]
- Functions: [explain each function]
- Output: [describe result structure]
### Intent Verification
Let me verify this matches your needs:
1. What are you trying to measure? [your goal here]
2. Is this a counter/gauge/histogram/summary? [metric type]
3. What time range interests you? [time window]
4. Do you need aggregation? If so, by which labels? [aggregation needs]
5. Is this for alerting, dashboarding, or analysis? [use case]
### Recommendations
[Based on the analysis, suggest improvements or alternatives]Interactive Dialogue
After validation, engage in 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]
Examples
See the examples/ directory for:
good_queries.promql: Well-written queries following best practicesbad_queries.promql: Common mistakes and anti-patterns (with corrections)optimization_examples.promql: Before/after optimization examples
Documentation
See the docs/ directory for:
best_practices.md: Comprehensive PromQL best practices guideanti_patterns.md: Detailed anti-pattern reference with explanations
Important Notes
1. Be Interactive: Always ask clarifying questions to understand user intent 2. Be Educational: Explain WHY something is wrong, not just THAT it's wrong 3. Be Helpful: Offer to rewrite queries, don't just criticize 4. Be Context-Aware: Consider the user's use case (alerting vs dashboarding) 5. Be Thorough: Check all four levels (syntax, semantics, performance, intent) 6. Be Practical: Suggest realistic optimizations, not theoretical perfection
Integration
This skill can be used:
- Standalone for query review
- During monitoring setup to validate alert rules
- When troubleshooting slow Prometheus queries
- As part of code review for recording rules
- For teaching PromQL to team members
Validation Tools
The skill uses two main Python scripts:
1. validate_syntax.py: Pure syntax checking using regex patterns 2. check_best_practices.py: Semantic and performance analysis
Both scripts output JSON for programmatic parsing and human-readable messages for display.
Success Criteria
A successful validation session should: 1. Identify all syntax errors 2. Detect semantic problems 3. Suggest at least one optimization (if applicable) 4. Clearly explain what the query does 5. Verify the query matches user intent 6. Provide actionable next steps
Known Limitations
The validation scripts have some limitations to be aware of:
Metric Type Detection
- Heuristic-based: Metric types (counter, gauge, histogram, summary) are inferred from naming conventions (e.g.,
_total,_bytes) - Custom metrics: Metrics with non-standard names may not be correctly classified
- Recommendation: When the script can't determine metric type, ask the user to clarify
High Cardinality Detection
- Conservative approach: The script flags metrics without label selectors, but some use cases legitimately query all series
- Recording rules: Queries using recording rule metrics (e.g.,
job:http_requests:rate5m) are valid without label filters - Recommendation: Use judgment - if the user knows their cardinality is manageable, the warning can be safely ignored
Semantic Validation
- No runtime context: The scripts cannot verify if metrics actually exist or if label values are valid
- Schema-agnostic: No knowledge of specific Prometheus deployments or metric schemas
- Recommendation: For production validation, test queries against actual Prometheus instances
Script Detection Coverage
The scripts detect common anti-patterns but cannot catch:
- Business logic errors (e.g., calculating the wrong KPI)
- Context-specific optimizations (depends on scrape interval, retention, etc.)
- Custom function behavior from extensions
Remember
The goal is not just to validate queries, but to help users write better PromQL and understand their monitoring data. Always be educational, interactive, and helpful!
PromQL Anti-Patterns
Comprehensive guide to common mistakes, anti-patterns, and pitfalls in PromQL queries.
Table of Contents
1. High Cardinality Issues 2. Incorrect Function Usage 3. Performance Anti-Patterns 4. Mathematical Errors 5. Label Matching Problems 6. Aggregation Mistakes 7. Time Range Issues 8. 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:mean5mWhen 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
1. Metric Types and Functions 2. Label Filtering 3. Aggregations 4. Time Ranges 5. Performance Optimization 6. Recording Rules 7. Histograms and Summaries 8. Alerting Queries 9. 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 codesEfficient 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:operationsExamples:
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
1. 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)2. Simpler aggregation - No need for le label in by() 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])))3. Enable native histograms in Prometheus - Requires configuration:
# prometheus.yml
global:
scrape_native_histograms: true4. 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.05Use 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]))
) * 100Success Rate
# Success rate as percentage
(
sum(rate(http_requests_total{status=~"2.."}[5m]))
/
sum(rate(http_requests_total[5m]))
) * 100Percentage Calculation
# Memory usage percentage
(
node_memory_usage_bytes
/
node_memory_total_bytes
) * 100Comparing 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:]) * 2Absent 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) |
---
Additional Resources
# Bad PromQL Query Examples (Anti-Patterns)
# These queries demonstrate common mistakes and anti-patterns
# Each bad example is followed by a corrected version
# ==============================================================================
# HIGH CARDINALITY - MISSING LABEL FILTERS
# ==============================================================================
# BAD: No label filters - matches ALL time series
http_requests_total
# GOOD: Specific label filters
http_requests_total{job="api-service", instance="prod-1"}
# ---
# BAD: Empty label matcher
http_requests_total{}
# GOOD: Add meaningful filters
http_requests_total{job="api", environment="production"}
# ==============================================================================
# REGEX OVERUSE
# ==============================================================================
# BAD: Using regex for exact match
http_requests_total{status=~"200"}
# GOOD: Use exact match operator
http_requests_total{status="200"}
# ---
# BAD: Overly broad regex
http_requests_total{status=~"2.."}
# GOOD: Be more specific or use exact matches
http_requests_total{status=~"2[0-9]{2}"}
# OR even better:
http_requests_total{status=~"200|201|204"}
# ==============================================================================
# MISSING RATE ON COUNTERS
# ==============================================================================
# BAD: Using counter without rate/increase
http_requests_total{job="api"}
# GOOD: Apply rate to get per-second rate
rate(http_requests_total{job="api"}[5m])
# ---
# BAD: Summing counter values directly
sum(http_requests_total)
# GOOD: Sum the rates
sum(rate(http_requests_total[5m]))
# ==============================================================================
# RATE ON GAUGES
# ==============================================================================
# BAD: Using rate on gauge metric
rate(node_memory_usage_bytes[5m])
# GOOD: Use gauge directly or with avg_over_time
node_memory_usage_bytes
# OR:
avg_over_time(node_memory_usage_bytes[5m])
# ---
# BAD: irate on gauge
irate(cpu_temperature_celsius[5m])
# GOOD: Use gauge value or calculate delta
cpu_temperature_celsius
# OR:
delta(cpu_temperature_celsius[5m])
# ==============================================================================
# AVERAGING QUANTILES
# ==============================================================================
# BAD: Averaging pre-calculated quantiles (mathematically invalid!)
avg(http_request_duration_seconds{quantile="0.95"})
# GOOD: Calculate quantile from histogram buckets
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# ---
# BAD: Summing quantiles
sum(response_time_seconds{quantile="0.99"})
# GOOD: Calculate from histogram
histogram_quantile(0.99,
sum by (job, le) (rate(response_time_seconds_bucket[5m]))
)
# ==============================================================================
# IMPROPER IRATE USAGE
# ==============================================================================
# BAD: irate with long time range (only uses last 2 samples!)
irate(http_requests_total[1h])
# GOOD: Use rate for long ranges
rate(http_requests_total[1h])
# OR: Use shorter range with irate
irate(http_requests_total[2m])
# ==============================================================================
# RATE WITH TOO SHORT RANGE
# ==============================================================================
# BAD: Rate range too short (less than 4x scrape interval)
rate(http_requests_total[30s])
# GOOD: Use at least 2-4 minutes for typical 15s scrape interval
rate(http_requests_total[2m])
# ==============================================================================
# MISSING RANGE VECTOR
# ==============================================================================
# BAD: rate without range vector
rate(http_requests_total)
# GOOD: Include time range
rate(http_requests_total[5m])
# ---
# BAD: increase without range
increase(http_requests_total{job="api"})
# GOOD: Add range vector
increase(http_requests_total{job="api"}[1h])
# ==============================================================================
# EXCESSIVE SUBQUERY RANGES
# ==============================================================================
# BAD: Subquery over 95 days (processes millions of samples!)
rate(http_requests_total[5m])[95d:1m]
# GOOD: Use recording rules or limit range
# Create recording rule:
# - record: job:http_requests:rate5m
# expr: rate(http_requests_total[5m])
# Then query:
job:http_requests:rate5m[7d:1m]
# ==============================================================================
# UNBOUNDED AGGREGATIONS
# ==============================================================================
# BAD: Aggregation without by/without clause
sum(rate(http_requests_total[5m]))
# GOOD: Specify grouping labels
sum by (job, instance) (rate(http_requests_total[5m]))
# ---
# BAD: Unclear aggregation
avg(node_memory_usage_bytes)
# GOOD: Be explicit about grouping
avg by (instance) (node_memory_usage_bytes)
# ==============================================================================
# HISTOGRAM MISTAKES
# ==============================================================================
# BAD: histogram_quantile without rate
histogram_quantile(0.95, sum by (le) (http_request_duration_seconds_bucket))
# GOOD: Use rate on buckets
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# ---
# BAD: histogram_quantile without 'le' label in grouping
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]))
)
# ==============================================================================
# OFFSET MISPLACEMENT
# ==============================================================================
# BAD: offset between metric name and range vector (invalid syntax)
http_requests_total offset 1h [5m]
# GOOD: offset after range vector selector
http_requests_total[5m] offset 1h
# GOOD: offset with instant vector
http_requests_total offset 1h
# GOOD: offset inside rate() function
rate(http_requests_total[5m] offset 1h)
# ==============================================================================
# INEFFICIENT LABEL MATCHING
# ==============================================================================
# BAD: Multiple OR conditions for same label
http_requests_total{job="api"} or http_requests_total{job="web"} or http_requests_total{job="worker"}
# GOOD: Use regex with alternatives
http_requests_total{job=~"api|web|worker"}
# ---
# BAD: Negating with multiple !=
http_requests_total{status!="200", status!="201", status!="204"}
# GOOD: Use negative regex
http_requests_total{status!~"200|201|204"}
# ==============================================================================
# COMPLEX QUERIES WITHOUT RECORDING RULES
# ==============================================================================
# BAD: Complex query repeated many times in dashboards/alerts
sum by (job, instance) (
rate(http_requests_total{status=~"5.."}[5m])
) /
sum by (job, instance) (
rate(http_requests_total[5m])
)
# GOOD: Create recording rule (in prometheus config):
# - record: job_instance:http_requests:error_rate5m
# expr: |
# sum by (job, instance) (
# rate(http_requests_total{status=~"5.."}[5m])
# ) /
# sum by (job, instance) (
# rate(http_requests_total[5m])
# )
# Then use:
job_instance:http_requests:error_rate5m
# ==============================================================================
# INCORRECT DIVISION
# ==============================================================================
# BAD: Division without matching labels
rate(http_requests_total{status="500"}[5m])
/
rate(http_requests_total[5m])
# GOOD: Ensure labels match in division
rate(http_requests_total{job="api", status="500"}[5m])
/
rate(http_requests_total{job="api"}[5m])
# ==============================================================================
# MISSING GROUP_LEFT/GROUP_RIGHT
# ==============================================================================
# BAD: One-to-many join without group_left
rate(http_requests_total[5m])
* on (job, instance)
service_info
# GOOD: Use group_left to include labels from right side
rate(http_requests_total[5m])
* on (job, instance) group_left (version, commit)
service_info
# ==============================================================================
# IMPLICIT AGGREGATION ISSUES
# ==============================================================================
# BAD: Comparing vectors with different label sets
http_requests_total{job="api", instance="host1"} > 1000
# GOOD: Aggregate first or match labels explicitly
sum by (job) (http_requests_total{job="api"}) > 1000
# ==============================================================================
# REDUNDANT FUNCTIONS
# ==============================================================================
# BAD: Nested unnecessary functions
avg(avg_over_time(node_cpu_percent[5m]))
# GOOD: Single aggregation is enough
avg_over_time(node_cpu_percent[5m])
# ---
# BAD: rate of rate (doesn't make sense)
rate(rate(http_requests_total[5m])[10m])
# GOOD: Just use rate once
rate(http_requests_total[5m])# Good PromQL Query Examples
# These queries follow best practices and demonstrate proper usage
# ==============================================================================
# RATE FUNCTIONS ON COUNTERS
# ==============================================================================
# Good: Calculate per-second request rate
rate(http_requests_total{job="api-service", status="200"}[5m])
# Good: Calculate 95th percentile latency from histogram
histogram_quantile(0.95, sum by (job, le) (rate(http_request_duration_seconds_bucket{job="api"}[5m])))
# Good: Calculate error rate percentage
(
rate(http_requests_total{job="api", status=~"5.."}[5m])
/
rate(http_requests_total{job="api"}[5m])
) * 100
# Good: Total increase over time window
increase(http_requests_total{job="api"}[1h])
# ==============================================================================
# AGGREGATIONS WITH PROPER GROUPING
# ==============================================================================
# Good: Sum requests by job and path
sum by (job, path) (rate(http_requests_total[5m]))
# Good: Average memory usage per instance, excluding pod label
avg without (pod) (node_memory_usage_bytes{job="node-exporter"})
# Good: Count number of instances per job
count by (job) (up{job="api-service"})
# Good: Top 5 services by request rate
topk(5, sum by (service) (rate(http_requests_total[5m])))
# ==============================================================================
# GAUGE METRICS
# ==============================================================================
# Good: Current memory usage (gauge)
node_memory_usage_bytes{instance="prod-1"}
# Good: Average memory over time window
avg_over_time(node_memory_usage_bytes{job="node-exporter"}[5m])
# Good: Maximum CPU usage in the last hour
max_over_time(node_cpu_usage_percent{instance="prod-1"}[1h])
# ==============================================================================
# SPECIFIC LABEL FILTERS
# ==============================================================================
# Good: Specific job and instance filters
up{job="prometheus", instance="localhost:9090"}
# Good: Multiple specific label filters
http_requests_total{
job="api-service",
environment="production",
datacenter="us-east-1"
}
# Good: Exact status code match
http_requests_total{status="404", job="api"}
# ==============================================================================
# COMPLEX QUERIES WITH PROPER STRUCTURE
# ==============================================================================
# Good: Request success rate with proper aggregation
sum by (job) (rate(http_requests_total{status=~"2.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
# Good: Memory usage percentage
(
node_memory_usage_bytes{job="node-exporter"}
/
node_memory_total_bytes{job="node-exporter"}
) * 100
# Good: Predict disk fill time (linear regression)
predict_linear(
node_filesystem_avail_bytes{mountpoint="/"}[1h],
4 * 3600
)
# Good: Change rate of metric (second derivative)
deriv(rate(http_requests_total{job="api"}[5m])[10m:1m])
# ==============================================================================
# PROPER RANGE VECTORS
# ==============================================================================
# Good: Appropriate rate range (at least 4x scrape interval)
rate(http_requests_total{job="api"}[2m])
# Good: Short range for irate
irate(http_requests_total{job="api"}[2m])
# Good: Count resets in counter
resets(http_requests_total{job="api"}[1h])
# ==============================================================================
# HISTOGRAM CALCULATIONS
# ==============================================================================
# Good: Calculate average from histogram
rate(http_request_duration_seconds_sum{job="api"}[5m])
/
rate(http_request_duration_seconds_count{job="api"}[5m])
# Good: Multi-quantile calculation
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
and
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
and
histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# ==============================================================================
# TIME-BASED QUERIES
# ==============================================================================
# Good: Compare current vs 1 hour ago
rate(http_requests_total{job="api"}[5m])
/
rate(http_requests_total{job="api"}[5m] offset 1h)
# Good: Query at specific time
http_requests_total{job="api"} @ 1609459200
# Good: Query at end of range for consistent topk
topk(5, rate(http_requests_total[1h] @ end()))
# ==============================================================================
# LABEL MANIPULATION
# ==============================================================================
# Good: Join metrics by common labels
rate(http_requests_total[5m])
* on (job, instance) group_left (version)
service_info
# Good: Replace label values
label_replace(
up{job="node-exporter"},
"instance",
"$1",
"instance",
"([^:]+):.*"
)
# ==============================================================================
# SUBQUERIES (USED APPROPRIATELY)
# ==============================================================================
# Good: Calculate max rate over reasonable window
max_over_time(rate(http_requests_total{job="api"}[5m])[30m:1m])
# Good: Detect spikes in rate
rate(http_requests_total{job="api"}[5m])
>
max_over_time(rate(http_requests_total{job="api"}[5m])[1h:1m]) * 2
# ==============================================================================
# BOOLEAN OPERATORS
# ==============================================================================
# Good: Find services with high error rate AND low request rate
(
rate(http_requests_total{status=~"5.."}[5m]) > 0.1
)
and
(
rate(http_requests_total[5m]) < 10
)
# Good: Alerts - memory usage high but not in maintenance
(node_memory_usage_percent > 90)
unless
(maintenance_mode{job="node-exporter"} == 1)
# ==============================================================================
# MULTI-DIMENSIONAL AGGREGATIONS
# ==============================================================================
# Good: CPU usage by node and core
sum by (node, cpu) (rate(node_cpu_seconds_total{mode!="idle"}[5m]))
# Good: Request rate by service, excluding internal calls
sum by (service) (
rate(http_requests_total{source!="internal"}[5m])
)
# ==============================================================================
# ABSENT CHECKS FOR ALERTING
# ==============================================================================
# Good: Check if metric is missing
absent(up{job="critical-service"})
# Good: Check if metric was present but now missing
absent_over_time(up{job="critical-service"}[5m])# PromQL Optimization Examples
# Before and after optimization with performance improvements
# ==============================================================================
# OPTIMIZATION 1: Reduce Cardinality with Specific Labels
# ==============================================================================
# BEFORE: Matches all pods across all namespaces
# Estimated series: 10,000+
# Query time: ~2-5 seconds
sum(rate(container_cpu_usage_seconds_total[5m]))
# AFTER: Filter to specific namespace and deployment
# Estimated series: 50
# Query time: ~100-200ms
sum(rate(container_cpu_usage_seconds_total{
namespace="production",
deployment="api-service"
}[5m]))
# Performance gain: 10-50x faster
# ==============================================================================
# OPTIMIZATION 2: Use Exact Match Instead of Regex
# ==============================================================================
# BEFORE: Regex match (slower pattern matching)
# Query time: ~500ms
sum(rate(http_requests_total{status=~"200"}[5m]))
# AFTER: Exact string match (index lookup)
# Query time: ~100ms
sum(rate(http_requests_total{status="200"}[5m]))
# Performance gain: 5x faster
# ==============================================================================
# OPTIMIZATION 3: Recording Rules for Complex Queries
# ==============================================================================
# BEFORE: Complex query run repeatedly (in alerting rules, dashboards)
# Query time per execution: ~1-2 seconds
# Executed: 100 times/minute (alerts + dashboards)
# Total compute: 100-200 seconds/minute
sum by (job, instance, path) (
rate(http_request_duration_seconds_sum{job="api"}[5m])
) /
sum by (job, instance, path) (
rate(http_request_duration_seconds_count{job="api"}[5m])
)
# AFTER: Pre-compute with recording rule, then query
# Recording rule (runs once per evaluation cycle):
# - record: job_instance_path:http_request_duration_seconds:mean5m
# expr: |
# sum by (job, instance, path) (
# rate(http_request_duration_seconds_sum{job="api"}[5m])
# ) /
# sum by (job, instance, path) (
# rate(http_request_duration_seconds_count{job="api"}[5m])
# )
# Query the pre-computed metric:
# Query time: ~50ms
job_instance_path:http_request_duration_seconds:mean5m
# Performance gain: 20-40x faster
# Resource savings: Significant reduction in Prometheus CPU usage
# ==============================================================================
# OPTIMIZATION 4: Appropriate irate vs rate Usage
# ==============================================================================
# BEFORE: irate over 1 hour (wastes the range, only uses last 2 samples)
# Not using the full hour of data effectively
irate(http_requests_total{job="api"}[1h])
# AFTER: Use rate for longer ranges (considers all samples in range)
# More accurate and stable results
rate(http_requests_total{job="api"}[1h])
# OR: Use irate with appropriate short range for high-frequency monitoring
irate(http_requests_total{job="api"}[2m])
# ==============================================================================
# OPTIMIZATION 5: Efficient Label Filtering with Regex
# ==============================================================================
# BEFORE: Multiple separate queries combined with OR
# Query time: ~800ms
# Memory: High (multiple independent queries)
sum(rate(http_requests_total{path="/api/users"}[5m]))
or
sum(rate(http_requests_total{path="/api/products"}[5m]))
or
sum(rate(http_requests_total{path="/api/orders"}[5m]))
# AFTER: Single query with regex alternation
# Query time: ~200ms
# Memory: Lower (single query execution)
sum by (path) (
rate(http_requests_total{path=~"/api/(users|products|orders)"}[5m])
)
# Performance gain: 4x faster, lower memory usage
# ==============================================================================
# OPTIMIZATION 6: Push Down Filters Before Aggregation
# ==============================================================================
# BEFORE: Filter after expensive aggregation
# Processes all series then filters
sum(rate(http_requests_total[5m])) and {job="api"}
# AFTER: Filter before aggregation
# Only processes relevant series
sum(rate(http_requests_total{job="api"}[5m]))
# Performance gain: 10-100x faster depending on cardinality
# ==============================================================================
# OPTIMIZATION 7: Use without() Instead of by() for High-Cardinality Labels
# ==============================================================================
# BEFORE: Enumerate all labels to keep (error-prone and verbose)
sum by (job, instance, environment, datacenter, region, cluster) (
rate(http_requests_total[5m])
)
# AFTER: Drop only the high-cardinality label you don't need
sum without (pod, container) (
rate(http_requests_total[5m])
)
# Benefit: More maintainable, less prone to errors
# ==============================================================================
# OPTIMIZATION 8: Simplify Nested Aggregations
# ==============================================================================
# BEFORE: Unnecessary nested aggregation
avg(sum by (instance) (rate(http_requests_total[5m])))
# AFTER: Single aggregation level
avg(rate(http_requests_total[5m]))
# Performance gain: 2x faster, clearer intent
# ==============================================================================
# OPTIMIZATION 9: Limit Subquery Time Ranges
# ==============================================================================
# BEFORE: 95-day subquery (extremely expensive!)
# Query time: 60+ seconds or timeout
# Memory: Several GB
max_over_time(rate(http_requests_total[5m])[95d:1m])
# AFTER: Reasonable time range OR use recording rules
# Query time: ~500ms
max_over_time(rate(http_requests_total[5m])[7d:1m])
# OR BETTER: Create recording rule for base metric
# - record: :http_requests:rate5m
# expr: rate(http_requests_total[5m])
# Then:
max_over_time(:http_requests:rate5m[30d:5m])
# Performance gain: 100x+ faster
# ==============================================================================
# OPTIMIZATION 10: Optimize Histogram Quantile Calculations
# ==============================================================================
# BEFORE: Calculate quantile without pre-aggregation
# Processes all label combinations
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket[5m])
)
# AFTER: Pre-aggregate by relevant labels
# Reduces series count before quantile calculation
histogram_quantile(0.95,
sum by (job, le) (
rate(http_request_duration_seconds_bucket[5m])
)
)
# Performance gain: 5-10x faster
# ==============================================================================
# OPTIMIZATION 11: Avoid Unnecessary label_replace Operations
# ==============================================================================
# BEFORE: Replace labels in query
# Adds processing overhead
label_replace(
rate(http_requests_total[5m]),
"service",
"$1",
"job",
"(.+)-service"
)
# AFTER: Fix labels at ingestion time (relabel_configs in scrape config)
# OR: Use recording rule if transformation is needed
# prometheus.yml:
# relabel_configs:
# - source_labels: [job]
# regex: '(.+)-service'
# target_label: service
# replacement: '$1'
rate(http_requests_total[5m])
# ==============================================================================
# OPTIMIZATION 12: Efficient Error Rate Calculation
# ==============================================================================
# BEFORE: Separate queries for errors and total
# Requires two metric scans
rate(http_requests_total{status=~"5.."}[5m])
/
rate(http_requests_total[5m])
# AFTER: Same but with shared label filters
# Ensures both sides filter the same series
rate(http_requests_total{job="api", status=~"5.."}[5m])
/
rate(http_requests_total{job="api"}[5m])
# EVEN BETTER: Use recording rules for frequently accessed ratios
# - 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]))
job:http_requests:error_rate5m{job="api"}
# ==============================================================================
# OPTIMIZATION 13: Aggregate Before Arithmetic Operations
# ==============================================================================
# BEFORE: Arithmetic first, then aggregate (more data to process)
sum(
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])
)
# AFTER: Aggregate first, then divide (fewer series)
sum(rate(http_request_duration_seconds_sum[5m]))
/
sum(rate(http_request_duration_seconds_count[5m]))
# Performance gain: 3-5x faster
# ==============================================================================
# OPTIMIZATION 14: Use topk/bottomk to Limit Results Early
# ==============================================================================
# BEFORE: Process all series, display only top 10 in dashboard
# Prometheus computes all series
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
# (Display limited in Grafana to 10)
# AFTER: Limit in query itself
# Prometheus only tracks top 10
topk(10,
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
)
# Performance gain: Lower memory usage, faster rendering
# ==============================================================================
# OPTIMIZATION 15: Combine Filters with AND Instead of Separate Queries
# ==============================================================================
# BEFORE: Multiple conditions checked separately
(rate(http_requests_total[5m]) > 100)
and
(rate(http_requests_total[5m]) < 1000)
# AFTER: Can be simplified depending on use case
# For alerting, this is fine. For other cases:
rate(http_requests_total[5m]) > 100 < 1000 # Not valid PromQL
# Actually, the BEFORE example is already optimal for this case
# But ensure both sides use same label filters:
rate(http_requests_total{job="api"}[5m]) > 100
and
rate(http_requests_total{job="api"}[5m]) < 1000
# ==============================================================================
# OPTIMIZATION 16: Minimize Work in Frequent Alerts
# ==============================================================================
# BEFORE: Complex calculation in every alert evaluation (every 15s)
# Cost: High CPU usage on Prometheus
alert: HighErrorRate
expr: |
(
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
) > 0.05
# AFTER: Use recording rule, alert on pre-computed metric
# prometheus.yml:
# groups:
# - name: recordings
# interval: 30s
# rules:
# - 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]))
#
# - name: alerts
# interval: 15s
# rules:
# - alert: HighErrorRate
# expr: job:http_requests:error_rate5m > 0.05
# Benefit: Alerts evaluate faster, lower CPU usage
# ==============================================================================
# OPTIMIZATION 17: Efficient Multi-Quantile Queries
# ==============================================================================
# BEFORE: Separate query for each quantile
# Dashboard with 3 panels, each running own query
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# AFTER: Recording rule for base aggregation
# - record: :http_request_duration_seconds_bucket:rate5m
# expr: sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
# Then query different quantiles of the same recording:
histogram_quantile(0.99, :http_request_duration_seconds_bucket:rate5m)
histogram_quantile(0.95, :http_request_duration_seconds_bucket:rate5m)
histogram_quantile(0.50, :http_request_duration_seconds_bucket:rate5m)
# Benefit: Shared computation, 3x reduction in work#!/usr/bin/env python3
"""
PromQL Best Practices Checker
Detects anti-patterns, performance issues, and optimization opportunities in PromQL queries.
Provides actionable suggestions for improving query efficiency and correctness.
"""
import re
import sys
import json
from typing import Dict, List, Tuple, Optional
class PromQLBestPracticesChecker:
"""Checks PromQL queries for best practices and anti-patterns"""
# Metric name patterns
COUNTER_SUFFIXES = ['_total', '_count', '_sum', '_bucket']
# Expanded gauge patterns based on common naming conventions
# See: https://prometheus.io/docs/practices/naming/
GAUGE_PATTERNS = [
'_bytes', # Memory, disk sizes (when not _bytes_total)
'_ratio', # Ratios like cache_hit_ratio
'_usage', # Resource usage metrics
'_percent', # Percentage values
'_gauge', # Explicitly named gauges
'_celsius', # Temperature metrics
'_fahrenheit', # Temperature metrics
'_temperature', # Temperature metrics
'_info', # Info metrics (always 1, with labels)
'_size', # Size measurements
'_current', # Current values (e.g., connections_current)
'_limit', # Limit values
'_available', # Available resources
'_free', # Free resources
'_used', # Used resources (when not a counter)
'_utilization', # Utilization percentages
'_capacity', # Capacity values
'_level', # Level measurements
]
# Rate functions
RATE_FUNCTIONS = ['rate', 'irate', 'increase', 'delta', 'idelta']
# Native histogram functions (Prometheus 2.40+/3.0)
NATIVE_HISTOGRAM_FUNCTIONS = [
'histogram_avg', 'histogram_stddev', 'histogram_stdvar',
'histogram_count', 'histogram_sum', 'histogram_fraction'
]
def __init__(self, query: str):
self.query = query.strip()
self.issues: List[Dict] = []
self.suggestions: List[Dict] = []
self.optimizations: List[Dict] = []
def check(self) -> Dict:
"""
Run all best practice checks
Returns:
Dict containing check results
"""
if not self.query:
return self._build_result()
# Check for anti-patterns
self._check_high_cardinality()
self._check_regex_overuse()
self._check_missing_rate_on_counters()
self._check_rate_on_gauges()
self._check_averaging_quantiles()
self._check_subquery_performance()
self._check_irate_range()
self._check_rate_range()
self._check_unbounded_queries()
self._check_aggregation_best_practices()
self._check_recording_rule_opportunity()
self._check_label_matcher_efficiency()
self._check_histogram_usage()
# Prometheus 3.0+ and additional checks
self._check_deprecated_functions()
self._check_predict_linear_range()
self._check_division_by_zero_risk()
self._check_changes_resets_alerting()
self._check_dimensional_metric_names()
# New checks based on documentation research
self._check_absent_with_aggregation()
self._check_vector_matching()
self._check_native_histogram_usage()
self._check_high_cardinality_labels_in_aggregation()
# Design pattern checks
self._check_mixed_metric_types()
return self._build_result()
def _check_high_cardinality(self):
"""Check for queries that might match too many time series"""
# Check for metric selectors with no or very few label filters
# Pattern: metric_name or metric_name{}
if re.search(r'\b[a-zA-Z_:][a-zA-Z0-9_:]*\s*\{\s*\}', self.query):
self.issues.append({
'type': 'high_cardinality',
'message': 'Query uses empty label matcher {} which may match many time series',
'severity': 'warning',
'recommendation': 'Add specific label filters like {job="...", instance="..."} to reduce cardinality'
})
# Check for bare metric names without selectors
# First, remove content inside {...} blocks and strings to avoid matching label names
query_without_selectors = self._strip_label_selectors_and_strings(self.query)
metric_pattern = r'\b([a-zA-Z_:][a-zA-Z0-9_:]*)\b(?!\s*[{\(])'
metrics_without_selectors = re.findall(metric_pattern, query_without_selectors)
# Filter out function names, keywords, and PromQL reserved words
reserved_words = {
# Aggregation operators
'sum', 'avg', 'min', 'max', 'count', 'stddev', 'stdvar', 'group',
'topk', 'bottomk', 'quantile', 'count_values', 'limitk', 'limit_ratio',
# Functions
'rate', 'irate', 'increase', 'delta', 'idelta', 'deriv', 'predict_linear',
'histogram_quantile', 'histogram_count', 'histogram_sum', 'histogram_fraction',
'histogram_avg', 'histogram_stddev', 'histogram_stdvar',
'abs', 'ceil', 'floor', 'round', 'sqrt', 'exp', 'ln', 'log2', 'log10',
'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh',
'deg', 'rad', 'sgn', 'clamp', 'clamp_max', 'clamp_min', 'pi',
'timestamp', 'time', 'minute', 'hour', 'day_of_month', 'day_of_week',
'days_in_month', 'month', 'year',
'label_replace', 'label_join', 'vector', 'scalar',
'changes', 'resets', 'absent', 'absent_over_time', 'present_over_time',
'avg_over_time', 'min_over_time', 'max_over_time', 'sum_over_time',
'count_over_time', 'quantile_over_time', 'stddev_over_time', 'stdvar_over_time',
'last_over_time', 'mad_over_time', 'sort', 'sort_desc', 'sort_by_label',
'sort_by_label_desc', 'holt_winters', 'double_exponential_smoothing', 'info',
# Prometheus 3.5+ experimental timestamp functions
'ts_of_max_over_time', 'ts_of_min_over_time', 'ts_of_last_over_time',
# Prometheus 3.7+ experimental functions
'first_over_time', 'ts_of_first_over_time',
# Keywords and operators
'by', 'without', 'and', 'or', 'unless', 'on', 'ignoring',
'group_left', 'group_right', 'bool', 'offset', 'start', 'end',
# Constants
'inf', 'nan'
}
for metric in metrics_without_selectors:
if metric.lower() not in reserved_words:
# Check if this metric has label filters in the ORIGINAL query
# A metric with filters looks like: metric_name{label="value"}
# We need to check if this metric is followed by a non-empty {...} block
escaped_metric = re.escape(metric)
has_filters = re.search(
rf'\b{escaped_metric}\s*\{{\s*[^}}]+\s*\}}',
self.query
)
if not has_filters:
self.issues.append({
'type': 'high_cardinality',
'message': f'Metric "{metric}" used without label filters',
'severity': 'warning',
'recommendation': f'Add label filters: {metric}{{job="...", instance="..."}}'
})
def _strip_label_selectors_and_strings(self, query: str) -> str:
"""
Remove content inside {...} label selectors, [...] range/subquery specifiers,
quoted strings, and grouping clauses.
This prevents label names and duration tokens from being misidentified as metric names.
Strips content from:
- [...] range vectors and subqueries (e.g. [5m], [7d:1h])
- by (...) clauses
- without (...) clauses
- on (...) clauses
- ignoring (...) clauses
- group_left(...) clauses
- group_right(...) clauses
"""
# Strip range vector and subquery contents: [5m], [1h], [7d:1h], [30m:]
# Duration tokens like ":1h" must not be matched as metric names.
# Replace bracket contents with spaces to preserve string length/positions.
query = re.sub(r'\[([^\]]*)\]', lambda m: '[' + ' ' * len(m.group(1)) + ']', query)
# Strip grouping clauses (by, without, on, ignoring, group_left, group_right)
# These contain label names, not metric names
query = re.sub(r'\b(by|without|on|ignoring|group_left|group_right)\s*\([^)]*\)', r'\1 ( )', query)
result = []
depth = 0
in_string = False
escape_next = False
i = 0
while i < len(query):
char = query[i]
if escape_next:
escape_next = False
i += 1
continue
if char == '\\':
escape_next = True
i += 1
continue
if char == '"':
in_string = not in_string
i += 1
continue
if in_string:
i += 1
continue
if char == '{':
depth += 1
result.append(' ') # Replace with space to preserve word boundaries
i += 1
continue
if char == '}':
depth = max(0, depth - 1)
result.append(' ')
i += 1
continue
if depth == 0:
result.append(char)
else:
# Inside {...}, replace with space to maintain positions
result.append(' ')
i += 1
return ''.join(result)
def _check_regex_overuse(self):
"""Check for regex matchers that could be exact matches"""
# Find regex matchers =~ and !~
regex_matchers = re.findall(r'([a-zA-Z_][a-zA-Z0-9_]*)\s*=~\s*"([^"]+)"', self.query)
# Regex metacharacters that indicate an actual regex pattern (not just a literal string)
# Note: . is a metacharacter meaning "any character", so "5.." is a regex pattern
regex_metacharacters = r'[\.\*\+\?\^\$\[\]\(\)\|\\]'
for label, pattern in regex_matchers:
# Check if the pattern contains any regex metacharacters
# If it does, it's a real regex and should NOT be converted to exact match
has_regex_chars = re.search(regex_metacharacters, pattern)
# Only suggest exact match if pattern is purely alphanumeric with underscores/hyphens
# and contains NO regex metacharacters
if not has_regex_chars and re.fullmatch(r'[a-zA-Z0-9_\-]+', pattern):
self.optimizations.append({
'type': 'regex_to_exact',
'message': f'Label matcher {label}=~"{pattern}" can be an exact match',
'severity': 'info',
'recommendation': f'Use {label}="{pattern}" instead of =~ for better performance'
})
# Check for simple prefix/suffix patterns that might be better structured
if pattern.endswith('.*'):
self.suggestions.append({
'type': 'regex_optimization',
'message': f'Regex pattern "{pattern}" uses wildcard suffix',
'severity': 'info',
'recommendation': 'Consider if you can use more specific label values'
})
def _check_missing_rate_on_counters(self):
"""Check if counter metrics are used without rate/increase"""
# Strip label selector contents and quoted strings before scanning for counter
# metric names. Without this, a counter-suffix name that appears inside a label
# VALUE (e.g. {label="http_requests_total"}) would be misidentified as a bare
# metric reference and produce a false-positive missing_rate warning.
# _check_high_cardinality() already uses the same stripping approach.
query_for_metric_scan = self._strip_label_selectors_and_strings(self.query)
# Find metric names that look like counters
metric_pattern = r'\b([a-zA-Z_:][a-zA-Z0-9_:]*(?:_total|_count|_sum|_bucket))\b'
counter_metrics = re.findall(metric_pattern, query_for_metric_scan)
for metric in counter_metrics:
# Check if it's wrapped in rate/irate/increase
escaped_metric = re.escape(metric)
if not re.search(rf'(?:rate|irate|increase|delta|idelta)\s*\([^)]*{escaped_metric}', self.query):
# Check if it's in histogram_quantile (buckets are used differently)
if not re.search(rf'histogram_quantile\s*\([^)]*{escaped_metric}', self.query):
# Skip _sum and _count metrics when used in histogram calculations
# (they're used for average: _sum / _count)
if metric.endswith('_sum') or metric.endswith('_count'):
# Check if this is part of a division for average calculation
base_metric = metric.rsplit('_', 1)[0]
if re.search(rf'{base_metric}_sum.*{base_metric}_count|{base_metric}_count.*{base_metric}_sum', self.query):
continue # Skip - this is a valid average calculation pattern
# Skip native histogram metrics (no _bucket suffix needed)
# Native histograms use histogram_avg, histogram_stddev, etc.
if re.search(rf'histogram_(?:avg|stddev|stdvar|count|sum|fraction)\s*\([^)]*{escaped_metric}', self.query):
continue
self.issues.append({
'type': 'missing_rate',
'message': f'Counter metric "{metric}" used without rate() or increase()',
'severity': 'warning',
'recommendation': f'Use rate({metric}[5m]) to get per-second rate'
})
def _check_rate_on_gauges(self):
"""Check if rate/irate is used on gauge metrics"""
# Find rate/irate/increase calls
rate_calls = re.findall(
r'(rate|irate|increase|delta|idelta)\s*\(\s*([a-zA-Z_:][a-zA-Z0-9_:]*)',
self.query
)
for func, metric in rate_calls:
# Check if metric name suggests it's a gauge
is_gauge = any(pattern in metric for pattern in self.GAUGE_PATTERNS)
is_counter = any(metric.endswith(suffix) for suffix in self.COUNTER_SUFFIXES)
if is_gauge and not is_counter:
self.issues.append({
'type': 'rate_on_gauge',
'message': f'{func}() used on gauge metric "{metric}"',
'severity': 'warning',
'recommendation': f'Gauges should not use rate(). Use avg_over_time({metric}[5m]) or remove rate()'
})
def _check_averaging_quantiles(self):
"""Check for aggregating pre-calculated quantiles with invalid operations.
avg() is the most obviously wrong case, but sum(), max(), and min() on
Prometheus summary quantile labels are equally invalid:
- sum(p95_from_instance_A, p95_from_instance_B) is not a meaningful p95
- max() gives the worst-case instance p95 but is often misread as "global p95"
- min() has the same confusion
The only correct approach is to calculate quantiles from histogram buckets
using histogram_quantile().
"""
# Matches any aggregation whose argument contains a {quantile="..."} selector.
# The selector unambiguously identifies Prometheus summary quantile label usage.
invalid_aggregations = r'(?:avg|sum|min|max|stddev|stdvar)\s*\([^)]*\{[^}]*quantile\s*='
match = re.search(invalid_aggregations, self.query)
if match:
# Extract which aggregation was used for a clearer error message.
agg_func = match.group(0).split('(')[0].strip()
self.issues.append({
'type': 'averaging_quantiles',
'message': f'{agg_func}() on pre-calculated quantile labels produces mathematically invalid results',
'severity': 'error',
'recommendation': 'Use histogram_quantile() with histogram buckets instead: histogram_quantile(0.95, sum by (le) (rate(metric_bucket[5m])))'
})
def _check_subquery_performance(self):
"""Check for potentially expensive subqueries"""
# Pattern: [...:...] subquery syntax
subquery_pattern = r'\[(\d+)([smhdwy])[^\]]*:\s*(\d+)?([smhdwy])?\]'
subqueries = re.findall(subquery_pattern, self.query)
for range_val, range_unit, res_val, res_unit in subqueries:
# Convert to approximate hours
range_hours = self._duration_to_hours(int(range_val), range_unit)
if range_hours > 24 * 7: # More than 7 days
self.issues.append({
'type': 'expensive_subquery',
'message': f'Subquery spans {range_val}{range_unit} which may be very slow',
'severity': 'warning',
'recommendation': 'Consider using recording rules or reducing the time range'
})
def _check_irate_range(self):
"""Check if irate() is used with appropriate time ranges"""
# irate() should use short ranges (typically < 5m)
irate_pattern = r'irate\s*\([^)]*\[(\d+)([smhdwy])\]'
irate_calls = re.findall(irate_pattern, self.query)
for duration, unit in irate_calls:
minutes = self._duration_to_minutes(int(duration), unit)
if minutes > 5:
self.issues.append({
'type': 'irate_long_range',
'message': f'irate() used with {duration}{unit} range - irate only looks at last 2 samples',
'severity': 'warning',
'recommendation': f'Use rate() for ranges > 5m, or reduce irate range to [2m]'
})
def _check_rate_range(self):
"""Check if rate() uses appropriate time ranges"""
# rate() range should be at least 4x scrape interval (typically >= 2m)
rate_pattern = r'rate\s*\([^)]*\[(\d+)(ms|s|m|h|d|w|y)\]'
rate_calls = re.findall(rate_pattern, self.query)
for duration, unit in rate_calls:
seconds = self._duration_to_seconds(int(duration), unit)
if seconds < 120: # Less than 2 minutes
self.issues.append({
'type': 'rate_short_range',
'message': f'rate() used with very short range [{duration}{unit}]',
'severity': 'warning',
'recommendation': 'Rate range should be at least 4x scrape interval, typically [2m] or more'
})
def _check_unbounded_queries(self):
"""Check for queries without sufficient constraints"""
# Look for aggregations without by/without clauses on potentially high-cardinality data
aggregations = ['sum', 'avg', 'min', 'max', 'count']
# Check if this appears to be an alerting query (has comparison operator)
# For alerting, fully aggregated results returning a single value is often intentional
is_alerting_query = bool(re.search(r'\s*(>|<|>=|<=|==|!=)\s*[\d\.]', self.query))
for agg in aggregations:
# Pattern: sum(...) without "by" or "without"
pattern = rf'{agg}\s*\([^)]+\)(?!\s*(?:by|without)\s*\()'
if re.search(pattern, self.query):
if is_alerting_query:
# For alerting queries, this is often intentional - use a softer message
self.suggestions.append({
'type': 'missing_aggregation_clause',
'message': f'{agg}() used without by() or without() clause (likely intentional for alerting)',
'severity': 'info',
'recommendation': f'Full aggregation is common for alerting queries. Add "by (label)" only if you need per-label alerts.'
})
else:
# For non-alerting queries, the standard recommendation applies
self.suggestions.append({
'type': 'missing_aggregation_clause',
'message': f'{agg}() used without by() or without() clause',
'severity': 'info',
'recommendation': f'Consider adding "by (label)" or "without (label)" to {agg}() for clearer results'
})
def _check_aggregation_best_practices(self):
"""Check aggregation operator usage"""
# Check for count() without by clause (might be intentional, but worth mentioning)
if re.search(r'count\s*\([^)]+\)(?!\s*by)', self.query):
self.suggestions.append({
'type': 'count_without_by',
'message': 'count() used without by() - this counts all matching series',
'severity': 'info',
'recommendation': 'If you want to count by label, use: count(...) by (label)'
})
def _check_recording_rule_opportunity(self):
"""Check if query is complex enough to benefit from recording rules"""
# Heuristics for complex queries:
# - Multiple nested functions
# - Multiple aggregations
# - Subqueries
# - Long expressions
complexity_score = 0
# Count function calls
func_count = len(re.findall(r'\b[a-z_]+\s*\(', self.query))
if func_count >= 3:
complexity_score += 1
# Check for nested aggregations
if re.search(r'(sum|avg|min|max)\s*\([^)]*\b(sum|avg|min|max)\s*\(', self.query):
complexity_score += 1
# Check for subqueries
if re.search(r'\[[^\]]+:[^\]]+\]', self.query):
complexity_score += 1
# Check query length
if len(self.query) > 150:
complexity_score += 1
if complexity_score >= 2:
self.suggestions.append({
'type': 'recording_rule_opportunity',
'message': 'Query is complex and may benefit from recording rules',
'severity': 'info',
'recommendation': 'Consider creating recording rules if this query is used frequently'
})
def _check_label_matcher_efficiency(self):
"""Check if label matchers could be more efficient"""
# Check for multiple OR conditions that might indicate need for regex
if self.query.count(' or ') >= 2:
self.suggestions.append({
'type': 'multiple_or_conditions',
'message': 'Multiple OR conditions found',
'severity': 'info',
'recommendation': 'Consider using regex matcher =~ "value1|value2|value3" if checking same label'
})
def _check_histogram_usage(self):
"""Check for proper histogram quantile calculation"""
# Check for histogram_quantile usage
if 'histogram_quantile' in self.query:
# Should include rate() on bucket metrics
# Note: The rate() can be nested inside aggregations, so we look for both
# histogram_quantile and rate() appearing anywhere in the query
has_rate = bool(re.search(r'\brate\s*\(', self.query))
has_bucket_metric = '_bucket' in self.query
# Only warn about missing rate if there's a _bucket metric (classic histogram)
# Native histograms don't have _bucket suffix
if has_bucket_metric and not has_rate:
self.issues.append({
'type': 'histogram_missing_rate',
'message': 'histogram_quantile() should use rate() on bucket metrics',
'severity': 'warning',
'recommendation': 'Use: histogram_quantile(0.95, sum by (le) (rate(metric_bucket[5m])))'
})
# Should aggregate by 'le' label (only for classic histograms with _bucket)
# Native histograms don't need 'le' label
if has_bucket_metric:
# Look for 'le' in any by() clause in the query
has_le_in_by = bool(re.search(r'\bby\s*\([^)]*\ble\b', self.query))
if not has_le_in_by:
self.issues.append({
'type': 'histogram_missing_le',
'message': 'histogram_quantile() with classic histograms should aggregate by (le) label',
'severity': 'warning',
'recommendation': 'Include "le" in the by() clause: sum by (job, le) (...)'
})
def _check_deprecated_functions(self):
"""Check for deprecated functions (Prometheus 3.0+)"""
# holt_winters is deprecated in Prometheus 3.0, renamed to double_exponential_smoothing
if re.search(r'\bholt_winters\s*\(', self.query):
self.issues.append({
'type': 'deprecated_function',
'message': 'holt_winters() is deprecated in Prometheus 3.0',
'severity': 'warning',
'recommendation': 'Use double_exponential_smoothing() instead (requires --enable-feature=promql-experimental-functions)'
})
def _check_predict_linear_range(self):
"""Check if predict_linear() uses appropriate time ranges"""
# predict_linear() with very short ranges gives unreliable predictions
predict_pattern = r'predict_linear\s*\([^)]*\[(\d+)(ms|s|m|h|d|w|y)\]'
predict_calls = re.findall(predict_pattern, self.query)
for duration, unit in predict_calls:
minutes = self._duration_to_minutes(int(duration), unit)
if minutes < 10:
self.issues.append({
'type': 'predict_linear_short_range',
'message': f'predict_linear() used with short range [{duration}{unit}]',
'severity': 'warning',
'recommendation': 'predict_linear() needs sufficient data for reliable predictions. Use at least [10m] or longer.'
})
def _check_division_by_zero_risk(self):
"""Check for potential division by zero issues"""
# Pattern: / rate(..._count...) or / rate(..._total...)
# This can be zero if no requests occurred
if re.search(r'/\s*(?:rate|increase)\s*\([^)]*(?:_count|_total)[^)]*\)', self.query):
self.suggestions.append({
'type': 'division_by_zero_risk',
'message': 'Division by rate() or increase() of counter may result in NaN if denominator is 0',
'severity': 'info',
'recommendation': 'Consider using "or vector(0)" or "> 0" filter to handle zero denominators'
})
def _check_changes_resets_alerting(self):
"""Check for changes() or resets() usage patterns"""
# changes() and resets() can miss events that happen between scrapes
if re.search(r'\b(changes|resets)\s*\(', self.query):
self.suggestions.append({
'type': 'changes_resets_limitation',
'message': 'changes() and resets() only detect changes between scraped samples',
'severity': 'info',
'recommendation': 'Events occurring between scrapes will be missed. For alerting, consider alternative approaches.'
})
def _check_dimensional_metric_names(self):
"""Check for dimensions embedded in metric names (anti-pattern)"""
# Embedding dimensions in metric names like: http_requests_GET_total, cpu_0_usage
# This is a bad practice per Google Cloud and Prometheus best practices
# Look for patterns like: metric_value_total or metric_123_something
# Common bad patterns: http_requests_GET_200_total, cpu_core0_usage
bad_patterns = [
r'\b[a-zA-Z_]+_(GET|POST|PUT|DELETE|PATCH)_[a-zA-Z_]+', # HTTP methods in name
r'\b[a-zA-Z_]+_\d+_[a-zA-Z_]+', # Numbers embedded (like cpu_0_usage)
r'\b[a-zA-Z_]+_(2\d{2}|3\d{2}|4\d{2}|5\d{2})_[a-zA-Z_]+', # HTTP status codes in name
]
for pattern in bad_patterns:
if re.search(pattern, self.query):
self.suggestions.append({
'type': 'dimensional_metric_name',
'message': 'Metric name appears to embed dimensions (method, status code, or index)',
'severity': 'info',
'recommendation': 'Move dimensions to labels instead of embedding in metric names. Example: http_requests_total{method="GET", status="200"}'
})
break # Only warn once
def _check_absent_with_aggregation(self):
"""
Check for absent() used with aggregation functions.
Per https://stackoverflow.com/questions/53191746/prometheus-absent-function
and https://www.robustperception.io/functions-to-avoid/
absent() returns 1 if no time series match the selector, 0 otherwise.
When combined with aggregation, it doesn't work as expected because:
- absent(sum(metric)) will return empty if ANY metric matches
- It cannot detect per-label absence
For label-aware absence detection, use:
group(present_over_time(metric[range])) by (labels)
unless
group(metric) by (labels)
"""
# Pattern: absent(aggregation_function(...))
if re.search(r'absent\s*\(\s*(sum|avg|min|max|count|group|stddev|stdvar)\s*\(', self.query):
self.issues.append({
'type': 'absent_with_aggregation',
'message': 'absent() with aggregation may not work as expected',
'severity': 'warning',
'recommendation': 'absent() checks if a selector returns no data. Aggregations return data if ANY series matches. For per-label absence, use: group(present_over_time(metric[range])) unless group(metric)'
})
# Pattern: absent(...) by (label) - absent doesn't support by()
if re.search(r'absent\s*\([^)]+\)\s*by\s*\(', self.query):
self.issues.append({
'type': 'absent_with_by',
'message': 'absent() does not support by() clause for per-label grouping',
'severity': 'error',
'recommendation': 'absent() returns a single value. For per-label absence detection, use: group(present_over_time(metric[range])) by (labels) unless group(metric) by (labels)'
})
def _check_vector_matching(self):
"""
Check for common vector matching mistakes with on/ignoring/group_left/group_right.
Per https://grafana.com/blog/2024/12/13/promql-vector-matching-what-it-is-and-how-it-affects-your-prometheus-queries/
and https://iximiuz.com/en/posts/prometheus-vector-matching/
Common issues:
1. Missing group_left/group_right for many-to-one joins
2. Using group_right when group_left should be used
3. Missing on() or ignoring() when label sets don't match
"""
query_lower = self.query.lower()
# Check for binary operations that might need vector matching
# Pattern: metric * metric or metric / metric without on() or ignoring()
binary_ops = ['*', '/', '+', '-', '%', '^']
has_binary_op = any(op in self.query for op in binary_ops)
has_vector_matching = 'on(' in query_lower or 'ignoring(' in query_lower
# Check for _info metric joins (common pattern)
# Info metrics typically need group_left
if re.search(r'\*\s*on\s*\([^)]+\)\s*[a-zA-Z_]+_info\b', self.query):
if 'group_left' not in query_lower and 'group_right' not in query_lower:
self.issues.append({
'type': 'info_metric_missing_group',
'message': 'Joining with _info metric without group_left()',
'severity': 'warning',
'recommendation': 'Info metric joins typically need group_left() to bring labels from the info metric. Use: metric * on(job, instance) group_left(label1, label2) info_metric'
})
# Check for group_left/group_right without on() or ignoring()
if re.search(r'\b(group_left|group_right)\s*\(', query_lower):
if 'on(' not in query_lower and 'ignoring(' not in query_lower:
self.issues.append({
'type': 'group_without_matching',
'message': 'group_left()/group_right() used without on() or ignoring()',
'severity': 'error',
'recommendation': 'group_left()/group_right() requires on() or ignoring() to specify matching labels'
})
# Check for on() with empty parentheses - this is valid but might be unintentional
if re.search(r'\bon\s*\(\s*\)', query_lower):
self.suggestions.append({
'type': 'on_empty_labels',
'message': 'on() with empty labels matches all series',
'severity': 'info',
'recommendation': 'on() with empty parentheses ignores all labels for matching. Ensure this is intentional.'
})
# Check for potential many-to-many matching (error-prone)
# If there's a binary op with on() but no group_left/group_right, it might fail at runtime
if has_vector_matching and 'group_left' not in query_lower and 'group_right' not in query_lower:
# This is just informational since one-to-one might be intended
self.suggestions.append({
'type': 'vector_matching_cardinality',
'message': 'Binary operation with on()/ignoring() assumes one-to-one matching',
'severity': 'info',
'recommendation': 'If you have many-to-one or one-to-many cardinality, add group_left() or group_right(). Error "multiple matches for labels" indicates cardinality mismatch.'
})
def _check_native_histogram_usage(self):
"""
Check for proper native histogram function usage (Prometheus 2.40+/3.0).
Per https://prometheus.io/docs/specs/native_histograms/
and https://prometheus.io/blog/2024/11/14/prometheus-3-0/
Native histograms:
- Don't need _bucket suffix
- Don't need 'le' label in aggregation
- Still need rate() for proper calculation
- Use histogram_avg, histogram_stddev, etc.
"""
# Check for native histogram functions
native_hist_pattern = r'\b(histogram_avg|histogram_stddev|histogram_stdvar)\s*\('
native_hist_matches = re.findall(native_hist_pattern, self.query)
for func in native_hist_matches:
# Check if rate() is used (required for native histograms too)
func_call_pattern = rf'{func}\s*\([^)]*'
if not re.search(rf'{func}\s*\(\s*rate\s*\(', self.query):
self.issues.append({
'type': 'native_histogram_missing_rate',
'message': f'{func}() should use rate() on the histogram metric',
'severity': 'warning',
'recommendation': f'Use: {func}(rate(histogram_metric[5m]))'
})
# Check for histogram_quantile with native histogram patterns
# Native histograms don't need 'le' in by() clause
if 'histogram_quantile' in self.query:
# Check if this looks like a native histogram query (no _bucket suffix)
# Native histogram: histogram_quantile(0.95, sum by (job) (rate(metric[5m])))
# Classic histogram: histogram_quantile(0.95, sum by (job, le) (rate(metric_bucket[5m])))
# Look for _bucket anywhere in the query (not just immediately after histogram_quantile)
# since the bucket metric could be inside nested functions
has_bucket_suffix = '_bucket' in self.query
has_le_in_by = bool(re.search(r'\bby\s*\([^)]*\ble\b', self.query))
# Only warn about unnecessary 'le' for native histograms (no _bucket suffix)
# If there's a _bucket metric, this is a classic histogram and 'le' IS required
if not has_bucket_suffix and has_le_in_by:
self.suggestions.append({
'type': 'native_histogram_unnecessary_le',
'message': 'Native histograms do not need "le" label in aggregation',
'severity': 'info',
'recommendation': 'For native histograms, simplify to: histogram_quantile(0.95, sum by (job) (rate(metric[5m])))'
})
# If has _bucket but no le - classic histogram missing le (already covered by _check_histogram_usage)
# If no _bucket and no le - could be native histogram (OK) or classic without le (error)
# Provide helpful info about histogram_count and histogram_sum
# These work with both native and classic histograms but differently
if re.search(r'\bhistogram_count\s*\(', self.query) or re.search(r'\bhistogram_sum\s*\(', self.query):
# Check if it's wrapping rate()
if not re.search(r'histogram_(?:count|sum)\s*\(\s*rate\s*\(', self.query):
self.suggestions.append({
'type': 'histogram_helper_without_rate',
'message': 'histogram_count()/histogram_sum() typically need rate() for meaningful results',
'severity': 'info',
'recommendation': 'Use: histogram_count(rate(histogram_metric[5m])) to get observations per second'
})
def _check_high_cardinality_labels_in_aggregation(self):
"""
Check for known high-cardinality label names inside aggregation group labels.
Per best_practices.md: Labels like user_id, session_id, request_id, IP addresses,
full URLs, and UUIDs create one series per unique value, which can be millions of
series. They should not appear in by(...) dimensions.
Important semantic note:
- by(...) keeps the listed labels in the grouping key (risky for high-cardinality).
- without(...) removes the listed labels from the grouping key (often the opposite
of the risk we're checking), so we intentionally do not warn on those labels.
"""
# Extract aggregation grouping clauses with their mode (by|without)
agg_clause_pattern = r'\b(by|without)\s*\(([^)]*)\)'
clauses = re.findall(agg_clause_pattern, self.query)
# High-cardinality label name indicators (from best_practices.md)
# These are either exact known names or suffix patterns
HIGH_CARDINALITY_EXACT = {
'ip', 'url', 'path', 'timestamp', 'uuid', 'tid',
}
HIGH_CARDINALITY_SUFFIXES = (
'_id', # user_id, session_id, request_id, trace_id, span_id, etc.
'_uuid', # any_uuid
'_ip', # client_ip, source_ip, etc.
'_url', # full_url, request_url, etc.
'_address', # ip_address, email_address (high cardinality)
)
found = []
for mode, clause in clauses:
if mode.lower() != 'by':
continue
# Split on commas and strip whitespace to get individual label names
label_names = [lbl.strip() for lbl in clause.split(',') if lbl.strip()]
for label in label_names:
lower = label.lower()
if lower in HIGH_CARDINALITY_EXACT:
found.append(label)
elif any(lower.endswith(suffix) for suffix in HIGH_CARDINALITY_SUFFIXES):
found.append(label)
if found:
label_list = ', '.join(dict.fromkeys(found)) # deduplicate, preserve order
self.issues.append({
'type': 'high_cardinality_aggregation_label',
'message': f'by(...) includes high-cardinality label(s): {label_list}',
'severity': 'warning',
'recommendation': (
f'Labels like {label_list} can have millions of unique values, '
'creating one time series per value and degrading query performance. '
'Remove them from by() or replace with lower-cardinality alternatives '
'(e.g. use "service" instead of "user_id").'
)
})
def _check_mixed_metric_types(self):
"""
Check if query combines fundamentally different metric types in a single expression.
Mixing counters, gauges, histograms, and summaries in arithmetic operations
often produces meaningless results. Each metric type has different semantics:
- Counters: Cumulative values that only increase
- Gauges: Point-in-time values that can go up or down
- Histograms: Bucketed observations for distribution analysis
- Summaries: Pre-calculated quantiles
Combining them (e.g., latency / memory + request_count) rarely makes sense.
EXCEPTION: histogram_quantile with _bucket metrics is NOT mixed types - this is
the correct pattern for classic histograms. The _bucket suffix doesn't indicate
a counter being used incorrectly; it's part of the histogram data model.
"""
# Detect metric types in the query
detected_types = set()
type_examples = {}
# Check if this is a histogram_quantile query with classic histograms
# In this case, _bucket metrics are expected and should not be flagged as "counter"
is_classic_histogram_query = 'histogram_quantile' in self.query and '_bucket' in self.query
# Find all metric names in the query (outside of {...} blocks)
query_clean = self._strip_label_selectors_and_strings(self.query)
metric_pattern = r'\b([a-zA-Z_:][a-zA-Z0-9_:]*)\b'
potential_metrics = re.findall(metric_pattern, query_clean)
# Filter out reserved words
reserved_words = {
'sum', 'avg', 'min', 'max', 'count', 'stddev', 'stdvar', 'group',
'topk', 'bottomk', 'quantile', 'count_values', 'limitk', 'limit_ratio',
'rate', 'irate', 'increase', 'delta', 'idelta', 'deriv', 'predict_linear',
'histogram_quantile', 'histogram_count', 'histogram_sum', 'histogram_fraction',
'histogram_avg', 'histogram_stddev', 'histogram_stdvar',
'abs', 'ceil', 'floor', 'round', 'sqrt', 'exp', 'ln', 'log2', 'log10',
'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh',
'deg', 'rad', 'sgn', 'clamp', 'clamp_max', 'clamp_min', 'pi',
'timestamp', 'time', 'minute', 'hour', 'day_of_month', 'day_of_week',
'days_in_month', 'month', 'year',
'label_replace', 'label_join', 'vector', 'scalar',
'changes', 'resets', 'absent', 'absent_over_time', 'present_over_time',
'avg_over_time', 'min_over_time', 'max_over_time', 'sum_over_time',
'count_over_time', 'quantile_over_time', 'stddev_over_time', 'stdvar_over_time',
'last_over_time', 'mad_over_time', 'sort', 'sort_desc', 'sort_by_label',
'sort_by_label_desc', 'holt_winters', 'double_exponential_smoothing', 'info',
# Prometheus 3.5+ experimental timestamp functions
'ts_of_max_over_time', 'ts_of_min_over_time', 'ts_of_last_over_time',
# Prometheus 3.7+ experimental functions
'first_over_time', 'ts_of_first_over_time',
'by', 'without', 'and', 'or', 'unless', 'on', 'ignoring',
'group_left', 'group_right', 'bool', 'offset', 'start', 'end',
'inf', 'nan'
}
metrics = [m for m in potential_metrics if m.lower() not in reserved_words]
for metric in metrics:
# Classify metric type
# For _bucket metrics in histogram_quantile queries, treat them as histogram components
# not as standalone counters
if metric.endswith('_bucket') and is_classic_histogram_query:
# This is part of a classic histogram query - don't flag as counter
continue
elif any(metric.endswith(suffix) for suffix in ['_total', '_count', '_sum', '_bucket']):
detected_types.add('counter')
type_examples.setdefault('counter', []).append(metric)
elif any(pattern in metric for pattern in self.GAUGE_PATTERNS):
detected_types.add('gauge')
type_examples.setdefault('gauge', []).append(metric)
elif 'quantile' in self.query and metric in self.query:
# Check if this metric is used with a quantile label selector
if re.search(rf'{re.escape(metric)}\s*\{{[^}}]*quantile\s*=', self.query):
detected_types.add('summary')
type_examples.setdefault('summary', []).append(metric)
# Check for histogram usage via histogram_quantile
# Only add 'histogram' as a type if it's NOT a classic histogram query
# (where _bucket metrics are expected and handled above)
if 'histogram_quantile' in self.query and not is_classic_histogram_query:
detected_types.add('histogram')
type_examples.setdefault('histogram', []).append('(histogram_quantile usage)')
# Warn if multiple different metric types are combined with arithmetic
if len(detected_types) >= 2:
# Check if there are arithmetic operators combining these
arithmetic_ops = ['+', '-', '*', '/']
has_arithmetic = any(op in self.query for op in arithmetic_ops)
if has_arithmetic:
type_list = ', '.join(sorted(detected_types))
examples = []
for t, metrics_list in type_examples.items():
examples.append(f"{t}: {metrics_list[0]}")
self.issues.append({
'type': 'mixed_metric_types',
'message': f'Query combines different metric types ({type_list}) in arithmetic operations',
'severity': 'warning',
'recommendation': f'Mixing metric types often produces meaningless results. Examples found: {"; ".join(examples)}. Consider separating into distinct queries or ensure the combination makes semantic sense.'
})
@staticmethod
def _duration_to_seconds(value: int, unit: str) -> int:
"""Convert duration to seconds"""
units = {
'ms': 0.001,
's': 1,
'm': 60,
'h': 3600,
'd': 86400,
'w': 604800,
'y': 31536000
}
return int(value * units.get(unit, 1))
@staticmethod
def _duration_to_minutes(value: int, unit: str) -> float:
"""Convert duration to minutes"""
return PromQLBestPracticesChecker._duration_to_seconds(value, unit) / 60
@staticmethod
def _duration_to_hours(value: int, unit: str) -> float:
"""Convert duration to hours"""
return PromQLBestPracticesChecker._duration_to_seconds(value, unit) / 3600
def _build_result(self) -> Dict:
"""Build the check result dictionary"""
all_findings = self.issues + self.suggestions + self.optimizations
has_errors = any(item['severity'] == 'error' for item in all_findings)
has_warnings = any(item['severity'] == 'warning' for item in all_findings)
if has_errors:
status = 'ERROR'
elif has_warnings:
status = 'WARNING'
elif self.optimizations or self.suggestions:
status = 'CAN_BE_IMPROVED'
else:
status = 'OPTIMIZED'
return {
'status': status,
'query': self.query,
'issues': self.issues,
'suggestions': self.suggestions,
'optimizations': self.optimizations,
'summary': {
'errors': len([i for i in self.issues if i['severity'] == 'error']),
'warnings': len([i for i in self.issues if i['severity'] == 'warning']),
'suggestions': len(self.suggestions),
'optimizations': len(self.optimizations)
}
}
def main():
"""Main entry point for the best practices checker"""
if len(sys.argv) < 2:
print(json.dumps({
'status': 'ERROR',
'message': 'Usage: check_best_practices.py "<promql_query>"'
}, indent=2))
sys.exit(1)
query = sys.argv[1]
checker = PromQLBestPracticesChecker(query)
result = checker.check()
print(json.dumps(result, indent=2))
# Exit with error code if there are errors
sys.exit(0 if result['summary']['errors'] == 0 else 1)
if __name__ == '__main__':
main()
Related skills
How it compares
Pick promql-validator for pre-deploy PromQL review; use generic code review skills when changes are application logic rather than monitoring queries.
FAQ
What does promql-validator check in Grafana changes?
promql-validator lints PromQL embedded in alert rules and dashboard JSON before those changes reach staging or production Grafana. The skill flags invalid functions, bad label matchers, and cardinality traps that would break panels or inflate metric series after deploy.
When should developers invoke promql-validator?
Developers should invoke promql-validator when editing Grafana alert YAML, recording rules, or dashboard panel queries and need static PromQL review before merge or apply. Use it in pull requests for observability repos to catch syntax and cardinality issues early.