A database is often the best place to calculate descriptive statistics: it already holds the filtered rows, understands their groups, and can reduce millions of observations before transferring a small result to the application. MySQL 8.4 provides the essential aggregates and a capable set of window functions. The less obvious statistics can be assembled from the same building blocks without installing a server-side extension.
The examples use a table in which one row represents one observation. That detail matters. If a join duplicates an observation before aggregation, every count, average, and variance after the join describes the duplicated data rather than the original population.
CREATE TABLE observation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
segment VARCHAR(40) NOT NULL,
observed_at DATETIME(6) NOT NULL,
value DECIMAL(18, 6) NULL,
weight DECIMAL(18, 6) NULL,
x DOUBLE NULL,
y DOUBLE NULL,
PRIMARY KEY (id),
INDEX observation_segment_time (segment, observed_at, id)
); Start with the Statistical Question
Before choosing a function, define the unit of observation, the population being filtered, and the meaning of a missing value. SQL makes it easy to calculate a precise answer to the wrong question. A customer-level analysis, for example, should normally aggregate orders to one row per customer before comparing customers.
Most MySQL aggregate functions ignore NULL. Consequently, COUNT(*) counts selected rows while COUNT(value) counts non-NULL measurements. Showing both is a useful audit:
SELECT
segment,
COUNT(*) AS rows_seen,
COUNT(value) AS measured_values,
COUNT(DISTINCT value) AS distinct_values,
MIN(value) AS minimum,
MAX(value) AS maximum,
AVG(value) AS mean,
STDDEV_POP(value) AS population_stddev,
STDDEV_SAMP(value) AS sample_stddev
FROM observation
WHERE observed_at >= '2026-01-01'
AND observed_at < '2027-01-01'
GROUP BY segment; Use the population functions when the selected rows are the complete population of interest. Use the sample functions when those rows are a sample from a larger process. VAR_POP() and STDDEV_POP() divide by n; VAR_SAMP() and STDDEV_SAMP() divide by n - 1. The shorter aliases VARIANCE(), STD(), and STDDEV() all mean the population form, so the explicit names make an analysis easier to review.
Choosing an Average
AVG(value) is the arithmetic mean and gives every non-missing row equal influence. A weighted mean is more appropriate when a row represents a different quantity, exposure, duration, or confidence:
SELECT
segment,
SUM(value * weight) / NULLIF(SUM(weight), 0) AS weighted_mean
FROM observation
WHERE value IS NOT NULL
AND weight > 0
GROUP BY segment; Rejecting zero and negative weights is part of the definition here, not merely protection from division by zero. If a domain permits signed weights, it needs a separate interpretation and validation rule.
Harmonic mean for rates
Rates such as requests per second or distance per unit time should be averaged through the underlying quantity. For positive rates value and workloads weight, the weighted harmonic mean is:
SELECT
segment,
SUM(weight) / NULLIF(SUM(weight / value), 0) AS harmonic_mean
FROM observation
WHERE value > 0
AND weight > 0
GROUP BY segment; Equal weights describe equal workloads. If the observations instead cover equal time intervals, total work divided by total time leads back to an arithmetic mean of the rates. Stating what is held equal prevents the common mistake of applying the harmonic mean to every quantity called a rate.
Geometric mean for multiplicative change
Growth factors and other multiplicative quantities are naturally averaged in logarithmic space. Averaging the logs before exponentiation also avoids constructing a potentially enormous intermediate product:
SELECT
segment,
EXP(SUM(weight * LN(value)) / NULLIF(SUM(weight), 0)) AS geometric_mean
FROM observation
WHERE value > 0
AND weight > 0
GROUP BY segment; The geometric and harmonic means require strictly positive values under these definitions. Silently dropping invalid rows can bias a result, so production reports should count or reject them explicitly.
Mode, Median, and Percentiles
Return every mode
A mode is a value with the highest frequency. LIMIT 1 hides ties and may choose a different winner from one execution to another. Ranking the frequencies returns every valid mode:
WITH frequencies AS (
SELECT segment, value, COUNT(*) AS frequency
FROM observation
WHERE value IS NOT NULL
GROUP BY segment, value
), ranked AS (
SELECT
segment,
value,
frequency,
DENSE_RANK() OVER (
PARTITION BY segment
ORDER BY frequency DESC
) AS frequency_rank
FROM frequencies
)
SELECT segment, value, frequency
FROM ranked
WHERE frequency_rank = 1
ORDER BY segment, value; Exact median without a UDF
MySQL 8.4 does not provide a native median aggregate. ROW_NUMBER() and COUNT() can select the middle row for an odd sample and the two middle rows for an even sample. The primary key breaks ties deterministically; it does not change the median because tied rows have the same value.
WITH ordered AS (
SELECT
segment,
value,
ROW_NUMBER() OVER (
PARTITION BY segment
ORDER BY value, id
) AS rn,
COUNT(*) OVER (PARTITION BY segment) AS sample_size
FROM observation
WHERE value IS NOT NULL
)
SELECT segment, AVG(value) AS median
FROM ordered
WHERE rn IN (
(sample_size + 1) DIV 2,
(sample_size + 2) DIV 2
)
GROUP BY segment; This computes the conventional midpoint median. It sorts every partition, so it is substantially more expensive than a one-pass average. An index may help produce the filtered rows, but a large grouped median can still need a temporary table and an explicit sort.
Nearest-rank percentiles
CUME_DIST() gives the fraction of rows less than or equal to each value. Taking the first value at or above a requested fraction produces an empirical nearest-rank percentile:
WITH distribution AS (
SELECT
segment,
value,
CUME_DIST() OVER (
PARTITION BY segment
ORDER BY value
) AS cumulative_fraction
FROM observation
WHERE value IS NOT NULL
)
SELECT
segment,
MIN(CASE WHEN cumulative_fraction >= 0.50 THEN value END) AS p50_nearest_rank,
MIN(CASE WHEN cumulative_fraction >= 0.90 THEN value END) AS p90_nearest_rank,
MIN(CASE WHEN cumulative_fraction >= 0.99 THEN value END) AS p99_nearest_rank
FROM distribution
GROUP BY segment; Percentile definitions are not interchangeable. The query returns observed values and does not interpolate between neighboring rows. If a report requires a particular interpolated convention, specify that convention and implement its lower and upper ranks explicitly rather than labeling every quantile simply as "the percentile."
Distributions, Buckets, and Long Tails
A histogram needs buckets of equal width when bar area is meant to represent frequency. With a width of 10 and an origin of zero, FLOOR() also assigns negative values consistently:
WITH buckets AS (
SELECT
segment,
FLOOR(value / 10) AS bucket_id
FROM observation
WHERE value IS NOT NULL
)
SELECT
segment,
bucket_id * 10 AS lower_bound,
(bucket_id + 1) * 10 AS upper_bound,
COUNT(*) AS frequency
FROM buckets
GROUP BY segment, bucket_id
ORDER BY segment, bucket_id; NTILE(10) answers a different question: it creates ten groups with roughly equal row counts, so their value ranges can have very different widths. That is useful for deciles but not a replacement for an equal-width histogram.
Long-tail reports often ask how many of the most frequent entities account for a chosen share of all events. A running sum replaces the user-variable tricks required by old MySQL versions:
WITH entity_frequency AS (
SELECT phrase_id, COUNT(*) AS frequency
FROM search_event
GROUP BY phrase_id
), cumulative AS (
SELECT
phrase_id,
frequency,
ROW_NUMBER() OVER (
ORDER BY frequency DESC, phrase_id
) AS frequency_rank,
SUM(frequency) OVER (
ORDER BY frequency DESC, phrase_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_events,
SUM(frequency) OVER () AS total_events
FROM entity_frequency
)
SELECT MIN(frequency_rank) AS entities_for_half_of_events
FROM cumulative
WHERE running_events >= total_events * 0.50; Variance and Standard Deviation
MySQL already implements the stable, native aggregate forms. Prefer them to algebraic rewrites such as AVG(value * value) - AVG(value) * AVG(value); subtracting two nearly equal large numbers can erase most of the useful precision.
SELECT
segment,
VAR_POP(value) AS population_variance,
STDDEV_POP(value) AS population_stddev,
VAR_SAMP(value) AS sample_variance,
STDDEV_SAMP(value) AS sample_stddev
FROM observation
GROUP BY segment; When raw rows cannot be retained and a statistic must be updated after every insert, a persisted running standard deviation can use Welford's algorithm instead of rescanning the group.
Skewness and Kurtosis
MySQL has no native skewness or kurtosis aggregate. They can be expressed through centered moments after calculating the mean. The following query returns population moment coefficients; it does not apply a small-sample bias correction.
WITH means AS (
SELECT segment, AVG(value) AS mean_value
FROM observation
WHERE value IS NOT NULL
GROUP BY segment
), centered AS (
SELECT o.segment, o.value - m.mean_value AS deviation
FROM observation AS o
JOIN means AS m ON m.segment = o.segment
WHERE o.value IS NOT NULL
), moments AS (
SELECT
segment,
COUNT(*) AS sample_size,
AVG(POWER(deviation, 2)) AS m2,
AVG(POWER(deviation, 3)) AS m3,
AVG(POWER(deviation, 4)) AS m4
FROM centered
GROUP BY segment
)
SELECT
segment,
CASE
WHEN sample_size >= 3 AND m2 > 0
THEN m3 / POWER(m2, 1.5)
END AS population_skewness,
CASE
WHEN sample_size >= 4 AND m2 > 0
THEN m4 / (m2 * m2) - 3
END AS population_excess_kurtosis
FROM moments; Excess kurtosis subtracts 3, making a normal distribution's theoretical value zero. Sample-adjusted estimators use different finite-sample factors; choose and document one when matching another statistics package.
Covariance, Correlation, and Linear Regression
Two-variable statistics must use the same paired rows throughout. Filtering x and y separately gives inconsistent counts and means when either column is NULL. Centering first also avoids the unstable shortcut AVG(x * y) - AVG(x) * AVG(y).
WITH paired AS (
SELECT segment, x, y
FROM observation
WHERE x IS NOT NULL
AND y IS NOT NULL
), means AS (
SELECT
segment,
AVG(x) AS mean_x,
AVG(y) AS mean_y
FROM paired
GROUP BY segment
), centered_sums AS (
SELECT
p.segment,
COUNT(*) AS sample_size,
MAX(m.mean_x) AS mean_x,
MAX(m.mean_y) AS mean_y,
SUM((p.x - m.mean_x) * (p.y - m.mean_y)) AS sum_xy,
SUM(POWER(p.x - m.mean_x, 2)) AS sum_xx,
SUM(POWER(p.y - m.mean_y, 2)) AS sum_yy
FROM paired AS p
JOIN means AS m ON m.segment = p.segment
GROUP BY p.segment
)
SELECT
segment,
sum_xy / sample_size AS covariance_population,
sum_xy / NULLIF(sample_size - 1, 0) AS covariance_sample,
sum_xy / NULLIF(SQRT(sum_xx * sum_yy), 0) AS pearson_r,
sum_xy / NULLIF(sum_xx, 0) AS regression_slope,
mean_y - (sum_xy / NULLIF(sum_xx, 0)) * mean_x AS regression_intercept
FROM centered_sums; Pearson's coefficient measures linear association, not causation. It is undefined when either variable has zero spread. The regression coefficients fit y = intercept + slope * x by ordinary least squares with an intercept. A production model still needs residual diagnostics, uncertainty estimates, and validation on data not used for fitting.
Ranks and Rolling Statistics
Window functions keep every observation while adding a statistic calculated over related rows. This makes rankings, cumulative distributions, and rolling summaries much clearer than the session-variable techniques used before MySQL 8.
SELECT
id,
segment,
observed_at,
value,
ROW_NUMBER() OVER window_order AS row_number,
RANK() OVER window_value AS rank_with_gaps,
DENSE_RANK() OVER window_value AS dense_rank,
PERCENT_RANK() OVER window_value AS percent_rank,
CUME_DIST() OVER window_value AS cumulative_distribution,
NTILE(4) OVER window_value AS quartile_bucket
FROM observation
WHERE value IS NOT NULL
WINDOW
window_order AS (
PARTITION BY segment
ORDER BY observed_at, id
),
window_value AS (
PARTITION BY segment
ORDER BY value
); A seven-observation rolling window uses an explicit ROWS frame. Including id makes the order deterministic when timestamps tie:
SELECT
id,
segment,
observed_at,
value,
COUNT(value) OVER rolling AS observations_in_window,
AVG(value) OVER rolling AS rolling_mean,
STDDEV_SAMP(value) OVER rolling AS rolling_sample_stddev
FROM observation
WINDOW rolling AS (
PARTITION BY segment
ORDER BY observed_at, id
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
ORDER BY segment, observed_at, id; This means seven rows, not seven days. For irregular observations, aggregate into the intended time grain first or use a temporal RANGE frame whose boundary expresses the actual interval. Writing the frame explicitly also avoids MySQL's ordered-window default, which is a peer-aware RANGE frame rather than a fixed row count.
Outlier Scores within Groups
A z-score expresses distance from the group mean in standard deviations. A CTE avoids repeating the window expressions and keeps zero-variance groups well-defined as NULL:
WITH scored AS (
SELECT
id,
segment,
value,
AVG(value) OVER (PARTITION BY segment) AS group_mean,
STDDEV_SAMP(value) OVER (PARTITION BY segment) AS group_stddev
FROM observation
WHERE value IS NOT NULL
)
SELECT
id,
segment,
value,
(value - group_mean) / NULLIF(group_stddev, 0) AS z_score
FROM scored
ORDER BY ABS((value - group_mean) / NULLIF(group_stddev, 0)) DESC; A large absolute z-score is a review signal, not proof that a row is erroneous. Heavy-tailed or multimodal data can make mean-and-standard-deviation thresholds misleading; median-based robust methods may be more appropriate there.
Correctness and Performance Checklist
- Filter before aggregating. Keep date, tenant, status, and quality filters in the earliest query block.
- Prevent join multiplication. Pre-aggregate one-to-many tables to the intended observation grain.
- Audit missing data. Report
COUNT(*)besideCOUNT(column)and pair-filter bivariate statistics. - Choose numeric types deliberately.
DECIMALpreserves exact stored amounts; statistical functions and logarithms can return approximateDOUBLEresults. - Make ordering deterministic. Add a stable tie-breaker to ordered windows whenever peers should be separate rows.
- Name the estimator. Record population versus sample, nearest-rank versus interpolated percentile, and raw versus bias-corrected moments.
- Measure the real statement. Use
EXPLAIN ANALYZEon representative data; medians, percentiles, and windows can sort or materialize large partitions. - Use a consistent snapshot. Related report queries should share one transaction snapshot when concurrent writes would otherwise mix different populations.
- Pre-aggregate repeated workloads. Summary tables or incremental statistics can be better than rescanning an event table for every dashboard refresh.
SQL is excellent for filtering, grouping, descriptive summaries, and preparing compact datasets. Inferential procedures, model selection, confidence intervals, and plots often belong in a statistics environment after MySQL has performed the expensive relational reduction. Keeping that boundary explicit is usually safer than expanding the database server with a custom UDF solely to imitate a full statistics package.
References
- MySQL Aggregate Oracle (2026), MySQL 8.4 Reference Manual, Aggregate Function Descriptions.
- MySQL Window Oracle (2026), MySQL 8.4 Reference Manual, Window Functions.
- MySQL Explain Oracle (2026), MySQL 8.4 Reference Manual, EXPLAIN Statement.