Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
SQL

Statistical Aggregations in SQL: PERCENTILE, MEDIAN, STDDEV, and Variance Functions for Data Analysis

Averages are only the beginning. This in-depth lesson teaches you how to use SQL's statistical aggregation functions — PERCENTILE_CONT, STDDEV, VAR_SAMP, and more — to analyze data distributions, detect outliers, and build professional distribution reports. Covers PostgreSQL, BigQuery, and SQL Server with real-world examples.

⚡ Practitioner21 min readAug 18, 2026Updated Aug 18, 2026
Statistical Aggregations in SQL: PERCENTILE, MEDIAN, STDDEV, and Variance Functions for Data Analysis
On this page
  • Prerequisites
  • The Problem with Averages Alone
  • Percentile Functions: PERCENTILE_CONT and PERCENTILE_DISC
  • PERCENTILE_CONT: Interpolated Values
  • PERCENTILE_DISC: Actual Values from Your Dataset
  • When to Use Which
  • Percentiles with GROUP BY: Segment-Level Analysis
  • Computing Median: The Practical Way
  • The Window Function Approach (for older systems)
  • Dialect Notes: BigQuery and SQL Server
  • Standard Deviation: Measuring Spread
STDDEV vs. STDDEV_POP
  • Interpreting Standard Deviation in Practice
  • Variance: The Square Before the Root
  • When Variance Matters Over Standard Deviation
  • Combining Statistical Functions with Window Functions
  • Z-Score: Identifying Outliers
  • Percentile Rank Within Groups
  • Building a Complete Distribution Summary Report
  • Hands-On Exercise
  • Setup: Create Sample Data
  • Your Tasks
  • Expected Outcomes
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting WITHIN GROUP
  • Mistake 2: Division by Zero in CV and Z-Score Calculations
  • Mistake 3: Using STDDEV on Small Groups
  • Mistake 4: BigQuery PERCENTILE Syntax Confusion
  • Mistake 5: Ignoring NULLs
  • Mistake 6: Interpreting Standard Deviation Without Units Context
  • Performance Considerations
  • Summary & Next Steps
  • Statistical Aggregations in SQL: PERCENTILE, MEDIAN, STDDEV, and Variance Functions for Data Analysis

    You've got a table of customer order values. Your boss asks: "What's a typical order size?" You return with AVG(order_total) — $147. She nods, then asks, "But are most orders actually near $147, or is that being dragged up by a few big spenders?" You don't have an answer. That's the moment you realize that averages, alone, are liars.

    Statistical aggregation functions — percentiles, median, standard deviation, and variance — are the tools that give averages context. They tell you about the shape of your data, not just its center. An average of $147 with a standard deviation of $12 means something completely different from an average of $147 with a standard deviation of $340. The first distribution is tight and predictable. The second is chaotic, and your business decisions should reflect that.

    By the end of this lesson, you'll be able to use SQL's statistical aggregation functions to perform meaningful distributional analysis directly in your database, without exporting to Python or R. You'll understand when and why to use each function, how to handle the syntax quirks across PostgreSQL, BigQuery, and SQL Server, and how to combine these functions into analytical queries that actually answer business questions.

    What you'll learn:

    • How PERCENTILE_CONT and PERCENTILE_DISC work and when to choose between them
    • How to compute median efficiently in SQL without workarounds
    • What STDDEV and VARIANCE measure and how to interpret their output in a business context
    • How to use these functions with GROUP BY and window functions for segment-level analysis
    • How to build a complete distribution summary report using a single SQL query

    Prerequisites

    You should be comfortable with standard SQL aggregation (GROUP BY, HAVING, SUM, AVG, COUNT), window functions (OVER, PARTITION BY, ORDER BY), and subqueries or CTEs. If window functions are shaky for you, take a pass through the window functions lesson in this learning path before continuing here.

    We'll use PostgreSQL syntax as our primary dialect, with explicit callouts for BigQuery and SQL Server where syntax diverges meaningfully.


    The Problem with Averages Alone

    Before diving into the functions themselves, it's worth building the right mental model for why you need them.

    Consider a SaaS company's monthly revenue per customer. Suppose you have:

    • 800 customers paying $50/month
    • 150 customers paying $200/month
    • 50 customers paying $2,000/month

    The average monthly revenue per customer comes out to roughly $195. But $195 describes almost nobody in that dataset. The median — the value where half your customers are above and half are below — is $50. That's a fundamentally different story.

    This is the classic problem with right-skewed distributions: a small number of high values pull the mean upward, making the average a poor representation of the "typical" case. Revenue data, response times, order sizes, bug counts, page load times — these are almost always right-skewed in production datasets.

    Standard deviation and variance add another dimension: they tell you how spread out your data is around the center. A customer support team averaging 4.2 minutes per ticket resolution sounds fine — until you learn the standard deviation is 18 minutes, meaning some customers are waiting well over an hour while the average looks acceptable.


    Percentile Functions: PERCENTILE_CONT and PERCENTILE_DISC

    SQL gives you two percentile functions, and understanding the difference between them will save you a debugging session.

    PERCENTILE_CONT: Interpolated Values

    PERCENTILE_CONT computes a continuous percentile — it interpolates between adjacent values when the exact percentile falls between two data points. This is what most statistical software uses by default.

    The syntax uses an ordered set aggregate, which looks a little unusual if you haven't seen it before:

    SELECT
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_total) AS median_order,
        PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY order_total) AS p25,
        PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY order_total) AS p75,
        PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY order_total) AS p90,
        PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY order_total) AS p95,
        PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY order_total) AS p99
    FROM orders
    WHERE order_date >= '2024-01-01';
    

    The WITHIN GROUP (ORDER BY ...) clause tells SQL how to sort your values before computing the percentile. This is required — the percentile concept is meaningless without an ordering.

    If you have 10 values and ask for the 50th percentile, there's no single value that sits exactly at the midpoint. PERCENTILE_CONT averages the 5th and 6th values (once sorted) to give you a smooth interpolated result.

    PERCENTILE_DISC: Actual Values from Your Dataset

    PERCENTILE_DISC returns a discrete percentile — the actual nearest value that exists in your dataset. If the exact percentile falls between two values, it returns the lower of the two.

    SELECT
        PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY order_total) AS median_order,
        PERCENTILE_DISC(0.25) WITHIN GROUP (ORDER BY order_total) AS p25,
        PERCENTILE_DISC(0.75) WITHIN GROUP (ORDER BY order_total) AS p75
    FROM orders
    WHERE order_date >= '2024-01-01';
    

    When to Use Which

    Use PERCENTILE_CONT when:

    • You're working with continuous numeric values (revenue, duration, temperature)
    • You want results that align with standard statistical software output
    • Interpolated values are meaningful for your metric

    Use PERCENTILE_DISC when:

    • You're working with values that must exist in your dataset (you want to cite an actual customer's order value, not an interpolated one)
    • Your data is discrete or categorical-ish (integer ratings, counts)
    • You need to reference back to a specific row

    Tip: For financial reporting, PERCENTILE_DISC is often more defensible — you can point to an actual transaction in your system. For statistical modeling and analysis, PERCENTILE_CONT is usually the right call.

    Percentiles with GROUP BY: Segment-Level Analysis

    The real power comes when you combine percentile functions with GROUP BY to compare distributions across segments.

    SELECT
        customer_segment,
        COUNT(*) AS order_count,
        ROUND(AVG(order_total)::numeric, 2) AS avg_order,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_total) AS median_order,
        PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY order_total) AS p25,
        PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY order_total) AS p75,
        PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY order_total) AS p90
    FROM orders
    JOIN customers USING (customer_id)
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_segment
    ORDER BY median_order DESC;
    

    This query tells you not just that "enterprise customers spend more on average" — it shows you where in the distribution they differ. Maybe enterprise and SMB customers have similar medians, but enterprise customers have a much higher p90, driven by occasional large bulk orders. That's an insight an average alone would hide.


    Computing Median: The Practical Way

    The median is just the 50th percentile, and PERCENTILE_CONT(0.5) is the cleanest way to compute it in modern SQL. But you'll encounter legacy code and edge cases where you need alternative approaches, so it's worth knowing the landscape.

    The Window Function Approach (for older systems)

    Before PERCENTILE_CONT was widely available, analysts computed median using a ranking trick:

    WITH ranked_orders AS (
        SELECT
            order_total,
            ROW_NUMBER() OVER (ORDER BY order_total) AS rn,
            COUNT(*) OVER () AS total_count
        FROM orders
        WHERE order_date >= '2024-01-01'
    )
    SELECT
        AVG(order_total) AS median_order
    FROM ranked_orders
    WHERE rn IN (
        FLOOR((total_count + 1) / 2.0),
        CEIL((total_count + 1) / 2.0)
    );
    

    This works but is more verbose and harder to extend to multiple percentiles. If your database supports PERCENTILE_CONT, use it.

    Dialect Notes: BigQuery and SQL Server

    BigQuery uses PERCENTILE_CONT and PERCENTILE_DISC as window functions, not aggregate functions. The syntax looks different:

    -- BigQuery syntax
    SELECT DISTINCT
        customer_segment,
        PERCENTILE_CONT(order_total, 0.5) OVER (PARTITION BY customer_segment) AS median_order,
        PERCENTILE_CONT(order_total, 0.75) OVER (PARTITION BY customer_segment) AS p75,
        PERCENTILE_CONT(order_total, 0.90) OVER (PARTITION BY customer_segment) AS p90
    FROM orders
    JOIN customers USING (customer_id)
    WHERE order_date >= '2024-01-01';
    

    Note the argument order is reversed in BigQuery: the column comes first, the percentile fraction second. The DISTINCT is needed because the window function returns a value for every row.

    SQL Server uses the same WITHIN GROUP syntax as PostgreSQL:

    -- SQL Server / PostgreSQL compatible
    SELECT
        customer_segment,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_total) AS median_order
    FROM orders
    JOIN customers USING (customer_id)
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_segment;
    

    Warning: In BigQuery, PERCENTILE_DISC does not support NULL values in the column being evaluated. Filter them out explicitly with a WHERE clause or a CASE expression.


    Standard Deviation: Measuring Spread

    Standard deviation tells you how far, on average, individual values deviate from the mean. A small standard deviation means your data clusters tightly around the average. A large one means your data is spread out — and your average is less reliable as a summary.

    STDDEV vs. STDDEV_POP

    SQL offers two variants:

    • STDDEV (also written STDDEV_SAMP): Sample standard deviation — uses n-1 in the denominator. Use this when your data is a sample drawn from a larger population.
    • STDDEV_POP: Population standard deviation — uses n in the denominator. Use this when your dataset is the entire population you're analyzing.

    In practice, if you're analyzing all orders from last year, you're looking at the whole population of last year's orders, so STDDEV_POP is technically correct. But unless your dataset is very small, the difference between the two is negligible. Most analysts just use STDDEV out of habit, and it's fine.

    SELECT
        customer_segment,
        COUNT(*) AS order_count,
        ROUND(AVG(order_total)::numeric, 2) AS avg_order,
        ROUND(STDDEV(order_total)::numeric, 2) AS stddev_order,
        ROUND(STDDEV(order_total) / NULLIF(AVG(order_total), 0) * 100, 1) AS cv_pct
    FROM orders
    JOIN customers USING (customer_id)
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_segment
    ORDER BY cv_pct DESC;
    

    The last column — cv_pct — is the coefficient of variation (CV): standard deviation divided by the mean, expressed as a percentage. CV is more useful than raw standard deviation for comparing spread across groups with different means. A $50 standard deviation on a $150 average order (33% CV) is very different from a $50 standard deviation on a $1,500 average order (3.3% CV).

    Interpreting Standard Deviation in Practice

    Here's a worked example. Suppose your query returns:

    customer_segment avg_order stddev_order cv_pct
    enterprise 1,240 890 71.8%
    smb 340 85 25.0%
    consumer 47 18 38.3%

    Enterprise customers have a very high CV — 71.8%. That tells you the enterprise segment is heterogeneous. Some enterprise customers have modest orders, others have massive ones. Treating "enterprise" as a single cohort for pricing or capacity planning will lead you astray.

    SMB customers are much more predictable. A 25% CV means the average is actually a good representation of a typical SMB order.


    Variance: The Square Before the Root

    Variance is the square of standard deviation. It's less intuitive to interpret directly (a variance of 79,210 square dollars is hard to contextualize), but it has mathematical properties that make it useful in certain contexts.

    SELECT
        customer_segment,
        ROUND(AVG(order_total)::numeric, 2) AS avg_order,
        ROUND(VAR_SAMP(order_total)::numeric, 2) AS variance_order,
        ROUND(STDDEV(order_total)::numeric, 2) AS stddev_order
    FROM orders
    JOIN customers USING (customer_id)
    GROUP BY customer_segment;
    

    Like standard deviation, there are two variants: VAR_SAMP (sample variance, n-1 denominator) and VAR_POP (population variance, n denominator). Some databases also accept VARIANCE as an alias for VAR_SAMP.

    When Variance Matters Over Standard Deviation

    The most common practical use of variance in SQL analysis is when you need to compare relative variability across different time periods or experiments. Because variance is additive for independent random variables, statisticians prefer it for formal tests. For day-to-day data analysis, standard deviation is more interpretable.

    One useful application: detecting performance regressions in engineering metrics.

    WITH weekly_metrics AS (
        SELECT
            DATE_TRUNC('week', event_timestamp) AS week_start,
            AVG(response_time_ms) AS avg_response_time,
            STDDEV(response_time_ms) AS stddev_response_time,
            VAR_SAMP(response_time_ms) AS variance_response_time,
            COUNT(*) AS request_count
        FROM api_events
        WHERE event_timestamp >= NOW() - INTERVAL '12 weeks'
        GROUP BY DATE_TRUNC('week', event_timestamp)
    )
    SELECT
        week_start,
        ROUND(avg_response_time::numeric, 1) AS avg_ms,
        ROUND(stddev_response_time::numeric, 1) AS stddev_ms,
        ROUND(variance_response_time::numeric, 0) AS variance_ms,
        request_count,
        -- Flag weeks where variance spiked more than 50% above the 12-week average
        CASE
            WHEN variance_response_time > 1.5 * AVG(variance_response_time) OVER ()
            THEN 'HIGH VARIANCE ⚠️'
            ELSE 'normal'
        END AS variance_status
    FROM weekly_metrics
    ORDER BY week_start;
    

    This query gives you a weekly performance overview and automatically flags weeks where variance spiked — even if the average looked okay. A deployment that causes occasional 5-second timeouts might not move the weekly average much, but it will absolutely show up in variance.


    Combining Statistical Functions with Window Functions

    The real analytical power comes from using these functions as window functions, which lets you compute statistics within a partition without collapsing your rows. This enables row-level analysis relative to group statistics.

    Z-Score: Identifying Outliers

    A z-score tells you how many standard deviations away from the mean a given value is. Values beyond ±2 or ±3 standard deviations are candidates for outlier investigation.

    WITH order_stats AS (
        SELECT
            order_id,
            customer_id,
            order_total,
            customer_segment,
            AVG(order_total) OVER (PARTITION BY customer_segment) AS segment_avg,
            STDDEV(order_total) OVER (PARTITION BY customer_segment) AS segment_stddev
        FROM orders
        JOIN customers USING (customer_id)
        WHERE order_date >= '2024-01-01'
    )
    SELECT
        order_id,
        customer_id,
        customer_segment,
        order_total,
        ROUND(segment_avg::numeric, 2) AS segment_avg,
        ROUND(segment_stddev::numeric, 2) AS segment_stddev,
        ROUND(
            (order_total - segment_avg) / NULLIF(segment_stddev, 0),
            2
        ) AS z_score
    FROM order_stats
    WHERE ABS((order_total - segment_avg) / NULLIF(segment_stddev, 0)) > 3
    ORDER BY ABS((order_total - segment_avg) / NULLIF(segment_stddev, 0)) DESC;
    

    This surfaces orders that are statistical outliers within their segment. An enterprise order for $250 might be normal overall, but if the enterprise segment average is $1,200 with a standard deviation of $300, that $250 order has a z-score of about -3.2 — and worth investigating for a data entry error, a discount applied incorrectly, or a contract issue.

    Percentile Rank Within Groups

    You can also use PERCENT_RANK() and CUME_DIST() as window functions to compute where each row falls within its group:

    SELECT
        customer_id,
        order_id,
        order_total,
        customer_segment,
        ROUND(PERCENT_RANK() OVER (
            PARTITION BY customer_segment
            ORDER BY order_total
        ) * 100, 1) AS percentile_within_segment,
        ROUND(CUME_DIST() OVER (
            PARTITION BY customer_segment
            ORDER BY order_total
        ) * 100, 1) AS cumulative_pct
    FROM orders
    JOIN customers USING (customer_id)
    WHERE order_date >= '2024-01-01'
    ORDER BY customer_segment, order_total;
    

    PERCENT_RANK() returns a value from 0 to 1 representing the relative rank (0 = lowest, 1 = highest). CUME_DIST() returns the fraction of rows with values less than or equal to the current row. The difference is subtle: PERCENT_RANK treats the lowest value as 0th percentile; CUME_DIST treats it as having some non-zero percentile.


    Building a Complete Distribution Summary Report

    Let's put everything together into a query you'd actually use in a real analytics workflow. This is a full distribution profile report for orders, broken out by segment and month.

    WITH monthly_orders AS (
        SELECT
            DATE_TRUNC('month', o.order_date) AS order_month,
            c.customer_segment,
            o.order_total
        FROM orders o
        JOIN customers c USING (customer_id)
        WHERE o.order_date >= '2023-01-01'
          AND o.order_date < '2025-01-01'
          AND o.order_total IS NOT NULL
          AND o.order_status != 'cancelled'
    ),
    distribution_summary AS (
        SELECT
            order_month,
            customer_segment,
            COUNT(*)                                                                    AS order_count,
            ROUND(AVG(order_total)::numeric, 2)                                        AS avg_order,
            ROUND(STDDEV(order_total)::numeric, 2)                                     AS stddev_order,
            ROUND(
                (STDDEV(order_total) / NULLIF(AVG(order_total), 0) * 100)::numeric, 1
            )                                                                           AS cv_pct,
            ROUND(MIN(order_total)::numeric, 2)                                        AS min_order,
            ROUND(PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY order_total)::numeric, 2) AS p10,
            ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY order_total)::numeric, 2) AS p25,
            ROUND(PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY order_total)::numeric, 2) AS median_order,
            ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY order_total)::numeric, 2) AS p75,
            ROUND(PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY order_total)::numeric, 2) AS p90,
            ROUND(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY order_total)::numeric, 2) AS p99,
            ROUND(MAX(order_total)::numeric, 2)                                        AS max_order,
            ROUND(
                (PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY order_total)
                 - PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY order_total))::numeric, 2
            )                                                                           AS iqr
        FROM monthly_orders
        GROUP BY order_month, customer_segment
    )
    SELECT
        TO_CHAR(order_month, 'YYYY-MM') AS month,
        customer_segment,
        order_count,
        avg_order,
        median_order,
        -- Skew indicator: if mean >> median, distribution is right-skewed
        ROUND((avg_order - median_order)::numeric, 2) AS mean_minus_median,
        stddev_order,
        cv_pct,
        p10,
        p25,
        p75,
        p90,
        p99,
        iqr,
        min_order,
        max_order
    FROM distribution_summary
    ORDER BY order_month, customer_segment;
    

    The mean_minus_median column is particularly useful. When the mean is significantly higher than the median, you have a right-skewed distribution — a few large orders pulling the average up. When they're close, your distribution is roughly symmetric. This gives you an instant skewness signal without computing formal skewness statistics.

    The IQR (interquartile range, p75 minus p25) is another robust spread measure — it's not affected by extreme outliers the way standard deviation is, because it only looks at the middle 50% of your data.


    Hands-On Exercise

    Work through this exercise using either a PostgreSQL or BigQuery sandbox. If you don't have a dataset handy, you can create one using the setup script below.

    Setup: Create Sample Data

    -- PostgreSQL version
    CREATE TABLE employee_salaries AS
    SELECT
        employee_id,
        department,
        job_level,
        base_salary,
        years_at_company
    FROM (
        VALUES
            (1, 'Engineering', 'junior', 72000, 1),
            (2, 'Engineering', 'junior', 68000, 2),
            (3, 'Engineering', 'mid', 98000, 3),
            (4, 'Engineering', 'mid', 105000, 4),
            (5, 'Engineering', 'mid', 112000, 5),
            (6, 'Engineering', 'senior', 145000, 7),
            (7, 'Engineering', 'senior', 162000, 9),
            (8, 'Engineering', 'senior', 178000, 11),
            (9, 'Engineering', 'principal', 220000, 14),
            (10, 'Engineering', 'principal', 265000, 16),
            (11, 'Sales', 'junior', 55000, 1),
            (12, 'Sales', 'junior', 58000, 2),
            (13, 'Sales', 'mid', 78000, 4),
            (14, 'Sales', 'mid', 82000, 3),
            (15, 'Sales', 'senior', 115000, 8),
            (16, 'Sales', 'senior', 138000, 10),
            (17, 'Marketing', 'junior', 61000, 1),
            (18, 'Marketing', 'mid', 85000, 4),
            (19, 'Marketing', 'senior', 120000, 7),
            (20, 'Marketing', 'principal', 195000, 12)
    ) AS t(employee_id, department, job_level, base_salary, years_at_company);
    

    Your Tasks

    Task 1: Department-Level Distribution

    Write a query that returns, for each department:

    • Count of employees
    • Average and median salary
    • Standard deviation and coefficient of variation
    • P25, P75, and IQR
    • A flag indicating whether the mean is more than 10% above the median (indicating right skew)

    Task 2: Outlier Detection

    Using window functions, identify any employees whose salary is more than 2 standard deviations from the mean for their department and job level combination. Return their employee_id, department, job_level, base_salary, and their z-score.

    Task 3: Interpret the Results

    Look at your Task 1 results. Which department shows the highest coefficient of variation, and what does that suggest about salary structure in that department? How does the Engineering department's median compare to its mean, and what does the difference tell you?

    Expected Outcomes

    For Task 1, you should see that Engineering has the highest CV (salary varies enormously by level), and its mean is pulled above its median by the principal-level salaries. For Task 2, depending on how your data shakes out with 20 rows, you may or may not find statistical outliers — but the query logic is the key deliverable.


    Common Mistakes & Troubleshooting

    Mistake 1: Forgetting WITHIN GROUP

    This is the most common syntax error with PERCENTILE_CONT:

    -- WRONG - this will fail
    SELECT PERCENTILE_CONT(0.5, order_total) FROM orders;
    
    -- RIGHT
    SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_total) FROM orders;
    

    The WITHIN GROUP (ORDER BY ...) clause is not optional. The error message from PostgreSQL ("function percentile_cont... does not exist") can be misleading — it's not a name problem, it's a syntax problem.

    Mistake 2: Division by Zero in CV and Z-Score Calculations

    If a segment has zero variance (all values are identical), STDDEV returns 0. Dividing by it will return NULL in PostgreSQL (due to the way it handles division by zero) or throw an error in other dialects. Always wrap divisors with NULLIF:

    -- Safe z-score calculation
    (order_total - AVG(order_total) OVER (PARTITION BY segment))
    / NULLIF(STDDEV(order_total) OVER (PARTITION BY segment), 0)
    

    Mistake 3: Using STDDEV on Small Groups

    Standard deviation is unreliable on small samples. If a group has 3 or 4 rows, the standard deviation is mathematically valid but statistically meaningless. Always check your counts:

    -- Guard against small-sample stddev interpretations
    SELECT
        customer_segment,
        COUNT(*) AS n,
        STDDEV(order_total) AS stddev_order,
        CASE WHEN COUNT(*) < 30 THEN 'insufficient sample' ELSE 'reliable' END AS reliability
    FROM orders
    GROUP BY customer_segment;
    

    Warning: A common analysis mistake is sorting a table by coefficient of variation and acting on the highest CV without noticing it's a segment with 4 orders. Always filter for minimum sample size before drawing conclusions from distributional statistics.

    Mistake 4: BigQuery PERCENTILE Syntax Confusion

    In BigQuery, if you try to use the WITHIN GROUP syntax, you'll get an error. BigQuery requires the window function syntax. Conversely, if you try the BigQuery syntax in PostgreSQL, it won't work either. Know your dialect.

    -- PostgreSQL / SQL Server
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_total)
    
    -- BigQuery (window function form, requires OVER clause or workaround)
    PERCENTILE_CONT(order_total, 0.5) OVER (PARTITION BY customer_segment)
    

    Mistake 5: Ignoring NULLs

    All of these functions (PERCENTILE_CONT, STDDEV, VAR_SAMP) ignore NULL values by default — which is usually what you want, but it can distort your count-based reasoning. If 30% of your order_total values are NULL (perhaps for cancelled or draft orders), your percentile calculations are based on 70% of the rows, but COUNT(*) still counts all rows.

    -- Make the NULL situation explicit
    SELECT
        COUNT(*) AS total_rows,
        COUNT(order_total) AS non_null_rows,
        COUNT(*) - COUNT(order_total) AS null_rows,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_total) AS median_order
    FROM orders;
    

    Mistake 6: Interpreting Standard Deviation Without Units Context

    Standard deviation is in the same units as your original data. A standard deviation of $500 on order revenue is meaningful. A standard deviation of 500 milliseconds on API response time is a major problem. A standard deviation of 0.5 on a 1-5 customer satisfaction rating is very different from 0.5 on a 1-100 NPS score. Always interpret standard deviation in the context of the metric's range and business impact.


    Performance Considerations

    Ordered set aggregates like PERCENTILE_CONT require sorting the data within each group, which is O(n log n). For large tables, this can be expensive.

    A few strategies:

    Filter early. Always push date range and status filters before the aggregation layer. Don't compute percentiles on a full fact table if you only need last quarter.

    Pre-aggregate when possible. If you need distribution summaries on a daily or weekly basis and your table has billions of rows, consider materializing the aggregation results into a summary table via a scheduled job.

    Use approximate functions for exploration. Some databases offer approximate percentile functions that are much faster for exploratory analysis on huge datasets:

    -- BigQuery: approximate percentiles using HyperLogLog-like approach
    SELECT
        APPROX_QUANTILES(order_total, 100)[OFFSET(50)] AS approx_median,
        APPROX_QUANTILES(order_total, 100)[OFFSET(90)] AS approx_p90
    FROM orders;
    

    PostgreSQL's pg_tdigest extension provides similar approximate percentiles if you're willing to add an extension.

    Avoid computing the same percentile multiple times. In the full report query above, we computed IQR by subtracting p25 from p75 in a single query pass. A common mistake is writing separate subqueries for each percentile — that's multiple sorts of the same data.


    Summary & Next Steps

    You now have a complete toolkit for distributional analysis in SQL. Here's a quick reference for the functions covered:

    Function What it computes Sample vs. Population
    PERCENTILE_CONT(f) WITHIN GROUP (ORDER BY col) Interpolated percentile N/A
    PERCENTILE_DISC(f) WITHIN GROUP (ORDER BY col) Nearest actual value percentile N/A
    STDDEV(col) or STDDEV_SAMP(col) Standard deviation Sample (n-1)
    STDDEV_POP(col) Standard deviation Population (n)
    VAR_SAMP(col) or VARIANCE(col) Variance Sample (n-1)
    VAR_POP(col) Variance Population (n)
    PERCENT_RANK() Relative rank as 0–1 fraction Window function
    CUME_DIST() Cumulative distribution 0–1 Window function

    The key mental shift is this: never let a mean stand alone in your analysis. Every average deserves a standard deviation next to it and a median check. When those three numbers are close together, your distribution is well-behaved and your average is trustworthy. When they diverge, you have a story to tell.

    Where to go from here:

    • Histogram approximation in SQL: Learn to build bucket-based frequency distributions using WIDTH_BUCKET() or NTILE() to visualize distributions without leaving SQL
    • Correlation in SQL: CORR(y, x) and REGR_SLOPE let you measure linear relationships between numeric columns directly in your database
    • Rolling statistics with window frames: Combine STDDEV with ROWS BETWEEN clauses to build moving averages and rolling standard deviations for time-series analysis
    • Statistical hypothesis testing: With the distributional foundations from this lesson, you're equipped to understand how A/B test analysis tools use variance to compute confidence intervals

    The goal is to make your SQL outputs more than just summaries — they should tell you the shape of your data, where it concentrates, and where it surprises you.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Advanced SQL Queries

    Previous

    Writing Readable SQL: Formatting, Aliasing, and Structuring Complex Queries

    Related Insights

    SQLFoundation

    Writing Readable SQL: Formatting, Aliasing, and Structuring Complex Queries

    16 min
    SQLExpert

    Query Rewriting with Common Subexpression Elimination: CTEs, Derived Tables, and Optimizer Hints for Maximum SQL Performance

    28 min
    SQLPractitioner

    Partitioning Strategies in SQL: Using Table Partitioning and Partition Pruning to Accelerate Queries on Large Datasets

    24 min

    On this page

    • Prerequisites
    • The Problem with Averages Alone
    • Percentile Functions: PERCENTILE_CONT and PERCENTILE_DISC
    • PERCENTILE_CONT: Interpolated Values
    • PERCENTILE_DISC: Actual Values from Your Dataset
    • When to Use Which
    • Percentiles with GROUP BY: Segment-Level Analysis
    • Computing Median: The Practical Way
    • The Window Function Approach (for older systems)
    • Dialect Notes: BigQuery and SQL Server
    • Standard Deviation: Measuring Spread
    • STDDEV vs. STDDEV_POP
    • Interpreting Standard Deviation in Practice
    • Variance: The Square Before the Root
    • When Variance Matters Over Standard Deviation
    • Combining Statistical Functions with Window Functions
    • Z-Score: Identifying Outliers
    • Percentile Rank Within Groups
    • Building a Complete Distribution Summary Report
    • Hands-On Exercise
    • Setup: Create Sample Data
    • Your Tasks
    • Expected Outcomes
    • Common Mistakes & Troubleshooting
    • Mistake 1: Forgetting WITHIN GROUP
    • Mistake 2: Division by Zero in CV and Z-Score Calculations
    • Mistake 3: Using STDDEV on Small Groups
    • Mistake 4: BigQuery PERCENTILE Syntax Confusion
    • Mistake 5: Ignoring NULLs
    • Mistake 6: Interpreting Standard Deviation Without Units Context
    • Performance Considerations
    • Summary & Next Steps