Numeric and Aggregate Functions

Aggregate queries are where PostgreSQL turns rowsets into measurements. The most important boundaries are not the function names themselves but the semantics around null handling, grouping grain, conditional aggregation, numeric safety, and subtotal labeling. PostgreSQL also has a few notable differences from SQL Server in this area: count(*) already returns bigint, core PostgreSQL does not include a built-in approx_count_distinct, and the native FILTER clause often expresses conditional aggregation more cleanly than CASE.

Scope

This note mirrors the SQL Server aggregate and numeric-function track with PostgreSQL equivalents. It covers aggregate semantics, count and summary functions, statistical aggregates, conditional and ratio patterns, scalar math, and multi-level grouping.

  • Aggregate semantics cover null treatment, grouped grain, and the difference between row counts and non-null counts.
  • Summary and statistical aggregates cover SUM, AVG, MIN, MAX, stddev_samp, and stddev_pop.
  • Conditional and ratio patterns cover the PostgreSQL-native FILTER clause and divide-by-zero protection with NULLIF.
  • Scalar math covers rounding, truncation, powers, roots, and random versus cryptographic random values.
  • Multi-level grouping covers ROLLUP, GROUPING SETS, and GROUPING(...) for subtotal labeling.

Aggregate Semantics

Before choosing a specific function, it is critical to know what counts as an input row, what happens to NULL, and whether the result is supposed to preserve detail or collapse it to grouped output.

Null treatment and count semantics

Every PostgreSQL aggregate except count(*) ignores null input values. That rule affects counts, averages, and every metric derived from them.

Aggregates ignore NULL unless the query counts rows explicitly

Use this baseline when teaching aggregate semantics or debugging surprising counts and averages. It is typically triggered by completeness analysis and null-sensitive metrics. The query is read-only. Its purpose is to show the universal null-handling rule in one compact result.

FieldSourceTypeMeaning
count_starCOUNT(*)bigintCounts all rows, including rows whose value is NULL.
count_vCOUNT(v)bigintCounts only rows where v is not NULL.
sum_vSUM(v)integerSum of non-null values only.
avg_vAVG(v)numericMean of non-null values only.
min_vMIN(v)integerMinimum non-null value.
max_vMAX(v)integerMaximum non-null value.

This query applies the core aggregates to a five-row input that contains two nulls.

SELECT
    COUNT(*) AS count_star,
    COUNT(v) AS count_v,
    SUM(v) AS sum_v,
    AVG(v) AS avg_v,
    MIN(v) AS min_v,
    MAX(v) AS max_v
FROM (VALUES (10), (20), (NULL), (30), (NULL)) AS t(v);
count_starcount_vsum_vavg_vmin_vmax_v
536020.00000000000000001030

The difference between count(*) and count(v) is the number of nulls in v. That same null-skipping rule is why avg_v is 60 / 3, not 60 / 5.

Measure missingness with COUNT(*) - COUNT(column)

Use this pattern when the query needs a completeness metric rather than a simple row count. It is typically triggered by feed audits, data-quality checks, and column-population monitoring. The query is read-only. Its purpose is to show the simplest exact missingness formula on live warehouse data.

FieldSourceTypeMeaning
total_rowsCOUNT(*)bigintTotal rows in the selected slice.
non_null_yield_rowsCOUNT(yield_zscore)bigintRows where yield_zscore is populated.
null_yield_rowsCOUNT(*) - COUNT(yield_zscore)bigintRows where yield_zscore is missing.

This query measures how many latest Euro Stoxx 50 score rows are missing yield_zscore.

SELECT
    COUNT(*) AS total_rows,
    COUNT(yield_zscore) AS non_null_yield_rows,
    COUNT(*) - COUNT(yield_zscore) AS null_yield_rows
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'euro_stoxx_50';
total_rowsnon_null_yield_rowsnull_yield_rows
50482

Two rows in the current Euro Stoxx 50 slice have no yield_zscore. That is an exact missingness count, not an inferred estimate.

COUNT(DISTINCT ...) answers a different question from row count

Use COUNT(DISTINCT ...) when the business question is about the number of unique values, not the number of rows. It is typically triggered by cardinality checks, dimensionality summaries, and coverage analysis. The query is read-only. Its purpose is to show the distinction between raw row volume and unique-value volume.

FieldSourceTypeMeaning
distinct_sectorsCOUNT(DISTINCT sector)bigintNumber of unique sector values in the selected slice.
total_rowsCOUNT(*)bigintTotal rows in the selected slice.

This query counts how many distinct sectors appear in the latest USA scoring slice.

SELECT
    COUNT(DISTINCT sector) AS distinct_sectors,
    COUNT(*) AS total_rows
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50';
distinct_sectorstotal_rows
950

There are 50 rows in the slice but only 9 distinct sector values. PostgreSQL computes this exactly in core SQL. If an approximate distinct count is required for very large-scale telemetry, that is typically an extension decision rather than a built-in function choice.

Summary and Statistical Aggregates

Once the grouping grain is correct, summary functions turn the rowset into totals, means, extrema, and variation measures. In PostgreSQL, these are ordinary aggregates with well-defined return types and null-skipping semantics.

SUM, AVG, MIN, MAX, and the standard-deviation family

These functions answer straightforward measurement questions, but they still need correct types and correct grain.

Summarize one live price slice with totals, extrema, and volatility

Use this pattern when the workload needs a compact statistical summary over a bounded slice. It is typically triggered by QA checks, exploratory analysis, and notebook or ETL validation. The query is read-only. Its purpose is to show the core summary functions together on a real trading slice.

FieldSourceTypeMeaning
sum_volumeSUM(volume)numericTotal traded volume across the slice.
avg_closeAVG(close)numericMean close price across the slice.
min_closeMIN(close)numericLowest close in the slice.
max_closeMAX(close)numericHighest close in the slice.
stddev_samp_closeSTDDEV_SAMP(close)numericSample standard deviation of close prices.
stddev_pop_closeSTDDEV_POP(close)numericPopulation standard deviation of close prices.

This query summarizes six recent AAPL rows from the USA silver OHLCV table.

SELECT
    ROUND(SUM(volume)::numeric, 0) AS sum_volume,
    ROUND(AVG(close)::numeric, 4) AS avg_close,
    ROUND(MIN(close)::numeric, 2) AS min_close,
    ROUND(MAX(close)::numeric, 2) AS max_close,
    ROUND(STDDEV_SAMP(close)::numeric, 6) AS stddev_samp_close,
    ROUND(STDDEV_POP(close)::numeric, 6) AS stddev_pop_close
FROM silver.stoxxusa50_ohlcv
WHERE symbol = 'AAPL'
  AND date BETWEEN DATE '2026-03-30' AND DATE '2026-04-07';
sum_volumeavg_closemin_closemax_closestddev_samp_closestddev_pop_close
250543961254.0550246.63258.864.1123173.754015

The sample and population standard deviations differ because the sample form divides by n - 1 while the population form divides by n. That distinction matters when the selected rows are treated as a sample from a broader process instead of the full population of interest.

Conditional Aggregation and Ratio Safety

Many analytical queries need several segmented metrics out of the same grouped scan. PostgreSQL’s FILTER clause is often the cleanest way to express that intent.

Segment rows inside the aggregate, then protect the denominator

The two recurring patterns are conditional counts or sums and safe ratios built from those grouped results.

Use FILTER for conditional counts and sums

Use FILTER when the query needs several related aggregates over the same grouped rowset but with different conditions. It is typically triggered by score distributions, status dashboards, and segmented totals. The query is read-only. Its purpose is to show the PostgreSQL-native alternative to repeated SUM(CASE ...) patterns.

FieldSourceTypeMeaning
sectorgold.scores_daily.sectorvarcharGrouping key of the current summary row.
sector_rowsCOUNT(*)bigintTotal rows in the sector group.
positive_rowsCOUNT(*) FILTER (WHERE composite_score > 0)bigintSector rows with positive scores.
non_positive_rowsCOUNT(*) FILTER (WHERE composite_score <= 0)bigintSector rows with zero or negative scores.

This query counts positive and non-positive latest USA scores per sector in one grouped pass.

SELECT
    sector,
    COUNT(*) AS sector_rows,
    COUNT(*) FILTER (WHERE composite_score > 0) AS positive_rows,
    COUNT(*) FILTER (WHERE composite_score <= 0) AS non_positive_rows
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50'
GROUP BY sector
HAVING COUNT(*) >= 3
ORDER BY positive_rows DESC, sector
LIMIT 8;
sectorsector_rowspositive_rowsnon_positive_rows
Technology15105
Communication Services541
Financial Services927
Healthcare523
Industrials321
Consumer Cyclical413
Consumer Defensive615

This is the same logical job that many SQL Server notes express with SUM(CASE WHEN ...), but PostgreSQL makes the condition part of the aggregate itself. The intent is easier to scan, especially when many segmented metrics are side by side.

Protect ratios with NULLIF

Use NULLIF when a grouped denominator might be zero and a safe null result is preferable to a runtime error. It is typically triggered by percentages, conversion rates, and per-group shares. The query is read-only. Its purpose is to show the standard divide-by-zero guard in PostgreSQL aggregate work.

FieldSourceTypeMeaning
sectorgold.scores_daily.sectorvarcharGrouping key of the current ratio row.
sector_rowsCOUNT(*)bigintTotal rows in the denominator group.
positive_rowsfiltered COUNT(*)bigintNumerator: positive-score rows.
positive_shareguarded divisionnumericPositive rows divided by total rows, with zero denominators converted to NULL.

This query turns the positive-row counts into sector shares while protecting the denominator explicitly.

SELECT
    sector,
    COUNT(*) AS sector_rows,
    COUNT(*) FILTER (WHERE composite_score > 0) AS positive_rows,
    ROUND((COUNT(*) FILTER (WHERE composite_score > 0))::numeric / NULLIF(COUNT(*), 0), 4) AS positive_share
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50'
GROUP BY sector
HAVING COUNT(*) >= 3
ORDER BY positive_share DESC, sector
LIMIT 8;
sectorsector_rowspositive_rowspositive_share
Communication Services540.8000
Industrials320.6667
Technology15100.6667
Healthcare520.4000
Consumer Cyclical410.2500
Financial Services920.2222
Consumer Defensive610.1667

Every denominator in this slice is non-zero, so the guard did not fire. The important point is that the safety boundary is encoded in the query rather than assumed from the current data.

Scalar Math Functions

Analytical SQL is rarely pure aggregation. PostgreSQL also provides a wide numeric function surface for rounding, truncation, roots, powers, and pseudo-random or cryptographic random values.

Row-wise numeric transformations

These functions operate per row or per expression, not across groups, but they often appear directly next to grouped metrics.

Apply common scalar math functions explicitly

Use this pattern when a query needs deterministic numeric transformation rather than grouped reduction. It is typically triggered by formatting, bucketing, scientific transforms, and synthetic-test calculations. The query is read-only. Its purpose is to show several core math operators side by side with their actual PostgreSQL outputs.

FieldSourceTypeMeaning
abs_valueABS(...)numericAbsolute value of the input.
sign_valueSIGN(...)numericSign of the input: negative, zero, or positive.
ceil_valueCEIL(...)numericSmallest integer not less than the input.
floor_valueFLOOR(...)numericLargest integer not greater than the input.
round_2dpROUND(..., 2)numericRounded value at two decimal places.
trunc_2dpTRUNC(..., 2)numericTruncated value at two decimal places.
sqrt_valueSQRT(...)numericSquare root of the input.
power_valuePOWER(...)numericExponentiation result.

This query demonstrates the core row-wise math functions that appear most often in analytical SQL.

SELECT
    ABS(-12.345::numeric) AS abs_value,
    SIGN(-12.345::numeric) AS sign_value,
    CEIL(12.345::numeric) AS ceil_value,
    FLOOR(12.345::numeric) AS floor_value,
    ROUND(12.345::numeric, 2) AS round_2dp,
    TRUNC(12.345::numeric, 2) AS trunc_2dp,
    SQRT(144.0) AS sqrt_value,
    POWER(1.05, 3) AS power_value;
abs_valuesign_valueceil_valuefloor_valueround_2dptrunc_2dpsqrt_valuepower_value
12.345-1131212.3512.3412.0000000000000001.1576250000000000

ROUND and TRUNC are often confused. The result makes the difference explicit: 12.345 rounds to 12.35 but truncates to 12.34.

Distinguish pseudo-random from cryptographic random values

Use this pattern when a query needs a random sample, test data, or a cryptographically stronger random byte sequence. It is typically triggered by lab work, data generation, or token construction. The query is read-only. Its purpose is to show that PostgreSQL exposes both lightweight pseudo-random generation and stronger randomness through pgcrypto.

FieldSourceTypeMeaning
pseudo_randomrandom()numericFast pseudo-random value in the range [0, 1).
crypto_bytes_hexencode(gen_random_bytes(4), 'hex')textFour cryptographically generated bytes rendered as hex.

This query contrasts random() with gen_random_bytes() from pgcrypto.

SELECT
    ROUND(random()::numeric, 6) AS pseudo_random,
    encode(gen_random_bytes(4), 'hex') AS crypto_bytes_hex;
pseudo_randomcrypto_bytes_hex
0.380646e399fa1c

random() is appropriate for fast non-cryptographic use such as sampling. When the value must not be guessable, use a cryptographic source from pgcrypto instead.

Multi-Level Grouping

PostgreSQL supports the same major grouping extensions used in enterprise SQL: ROLLUP, CUBE, and GROUPING SETS. The critical operational issue is subtotal labeling, because subtotal rows introduce NULL markers that can be confused with real source nulls.

Hierarchical and explicit subtotal generation

ROLLUP produces hierarchical subtotal levels. GROUPING SETS lets the author name the exact subtotal combinations required. GROUPING(...) reveals whether a NULL in the output is a real value or a subtotal marker.

Label subtotal rows from ROLLUP with GROUPING(...)

Use ROLLUP when the report needs detail rows plus hierarchical subtotals and a grand total. It is typically triggered by management reporting and multidimensional summaries. The query is read-only. Its purpose is to show the subtotal rows and the grouping flags that identify them.

FieldSourceTypeMeaning
countrygold.scores_daily.countryvarcharCountry dimension in the rollup hierarchy.
sectorgold.scores_daily.sectorvarcharSector dimension nested beneath country.
row_countCOUNT(*)bigintNumber of rows at the current grouping level.
g_countryGROUPING(country)integer1 when country is a rollup-generated subtotal marker.
g_sectorGROUPING(sector)integer1 when sector is a rollup-generated subtotal marker.

This query rolls up latest Euro Stoxx 50 rows by country and sector and exposes which nulls are subtotal markers.

SELECT
    country,
    sector,
    COUNT(*) AS row_count,
    GROUPING(country) AS g_country,
    GROUPING(sector) AS g_sector
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'euro_stoxx_50'
  AND country IN ('Germany', 'France', 'Netherlands')
GROUP BY ROLLUP(country, sector)
ORDER BY country NULLS LAST, sector NULLS LAST;
countrysectorrow_countg_countryg_sector
FranceBasic Materials100
FranceConsumer Cyclical200
FranceConsumer Defensive200
FranceEnergy100
FranceFinancial Services200
FranceHealthcare200
FranceIndustrials400
FranceTechnology100
France1501
GermanyBasic Materials100
GermanyCommunication Services100
GermanyConsumer Cyclical400
GermanyFinancial Services300
GermanyHealthcare100
GermanyIndustrials400
GermanyTechnology200
Germany1601
NetherlandsConsumer Cyclical100
NetherlandsConsumer Defensive100
NetherlandsFinancial Services100
NetherlandsHealthcare100
NetherlandsIndustrials200
NetherlandsTechnology200
Netherlands801
3911

The country subtotal rows have g_sector = 1, and the grand total has both flags set to 1. That is how PostgreSQL distinguishes subtotal null markers from real null values.

Use GROUPING SETS when only specific subtotal levels are required

Use GROUPING SETS when the report needs a selective list of subtotal levels instead of the full hierarchy produced by ROLLUP or the full power set produced by CUBE. It is typically triggered by curated report layouts and cost-sensitive grouped output. The query is read-only. Its purpose is to show explicit subtotal selection.

FieldSourceTypeMeaning
countrygold.scores_daily.countryvarcharCountry subtotal dimension when present.
sectorgold.scores_daily.sectorvarcharSector subtotal dimension when present.
row_countCOUNT(*)bigintNumber of rows in the requested grouping set.

This query requests only country totals, sector totals, and the grand total, skipping the country-sector detail combinations entirely.

SELECT
    country,
    sector,
    COUNT(*) AS row_count
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'euro_stoxx_50'
  AND country IN ('Germany', 'France', 'Netherlands')
GROUP BY GROUPING SETS ((country), (sector), ())
ORDER BY country NULLS LAST, sector NULLS LAST;
countrysectorrow_count
France15
Germany16
Netherlands8
Basic Materials2
Communication Services1
Consumer Cyclical7
Consumer Defensive3
Energy1
Financial Services6
Healthcare4
Industrials10
Technology5
39

This is the selective-subtotal case where GROUPING SETS is clearer than ROLLUP. The query asks only for the country totals, the sector totals, and the grand total, so that is all PostgreSQL returns.

Practical Rules

Use grouped aggregates when the result should collapse to one row per group. Use window aggregates when the detail row must survive. Keep PostgreSQL-specific differences explicit rather than assuming SQL Server semantics transfer unchanged.

NeedPostgreSQL patternWhy
Count all rowsCOUNT(*)Counts rows regardless of nulls and already returns bigint.
Count populated valuesCOUNT(column)Skips rows where the expression is null.
Exact unique-value countCOUNT(DISTINCT column)Core PostgreSQL exact distinct counting.
Segmented aggregates in one passFILTER (WHERE ...)Cleaner than repeating CASE inside each aggregate.
Safe ratio... / NULLIF(denominator, 0)Prevents divide-by-zero failures.
Summary dispersionSTDDEV_SAMP, STDDEV_POPChoose sample or population semantics deliberately.
Fast non-crypto randomnessrandom()Good for sampling and tests, not security.
Stronger random bytesgen_random_bytes()Use when unpredictability matters.
Hierarchical subtotalsROLLUP plus GROUPING(...)Produces detail, subtotals, and grand total with explicit labels.
Selective subtotal listGROUPING SETSProduces only the requested subtotal levels.