Filter concepts by levelShowing all levels.

PostgreSQL · Section 5

Window Functions

Level
intermediate
Read
30 min
Concepts
5

How OVER()/PARTITION BY/ORDER BY compute across related rows without collapsing them the way GROUP BY does, the ranking and offset function family, the running-total and top-N-per-group recipes built from them, the frame concept that turns a running total into a true moving average, and when a window function is the better choice over a self join.

PostgreSQL overview

What is true here

  1. OVER() computes across a set of related rows while keeping every row in the output, unlike GROUP BY.
  2. ROW_NUMBER never ties; RANK leaves gaps after ties; DENSE_RANK does not.
  3. A running total is SUM() with ORDER BY and the default frame; a moving average needs an explicit ROWS frame.
  4. The default frame is "start of partition through the current row" whenever ORDER BY is present.
  5. A window function replaces most self joins for "this row vs its own group" questions, in a single pass.

What you will be able to do

  • Explain why a window function keeps every row while GROUP BY collapses them
  • Choose between ROW_NUMBER, RANK and DENSE_RANK based on how ties should be handled
  • Build a running total and a true fixed-window moving average, and explain the difference
  • Recognize when a window function is simpler than the equivalent self join or subquery
The three parts of a window function call

PARTITION BY

groups rows, keeps them all

ORDER BY

defines row sequence within the partition

Frame (ROWS/RANGE)

which rows in the partition count

Per-row result

attached to every original row

  • PARTITION BY — groups rows, keeps them all
    • leads to ORDER BY
  • ORDER BY — defines row sequence within the partition
    • leads to Frame (ROWS/RANGE)
  • Frame (ROWS/RANGE) — which rows in the partition count
    • leads to Per-row result
  • Per-row result — attached to every original row

The window mechanism

OVER(), PARTITION BY and ORDER BY — the machinery every window function shares.

OVER(), PARTITION BY and ORDER BY in Window Expressions

coreintermediate

OVER() turns an aggregate-like function into a window function: it computes across a set of related rows but keeps every row in the output, instead of collapsing them the way GROUP BY does. PARTITION BY defines which rows are "related"; ORDER BY inside OVER() defines the order the window function sees them in.

Think of it as

GROUP BY answers "one row per group." A window function answers "one row per input row, but computed with awareness of its group." PARTITION BY inside OVER() is doing the same conceptual job as GROUP BY — splitting rows into groups — except the rows are never actually collapsed; each row keeps its own identity while a value is computed across its partition.

sql
SELECT name, dept, salary,
    avg(salary) OVER (PARTITION BY dept ORDER BY salary) AS running_dept_avg
FROM employees;

What we're doing: Show every employee row alongside their department's average salary, without collapsing any rows the way GROUP BY would.

window_basics.sqlsql
CREATE TABLE employees (id SERIAL PRIMARY KEY, name TEXT, dept TEXT, salary NUMERIC);

INSERT INTO employees (name, dept, salary) VALUES
    ('Ada', 'Engineering', 120000),
    ('Grace', 'Engineering', 110000),
    ('Linus', 'Sales', 90000);

SELECT name, dept, salary,
    avg(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
3–6
Two departments: Engineering (2 employees) and Sales (1 employee).
8
avg(salary) OVER (PARTITION BY dept) computes each row's department average without collapsing the 3 rows into 2.
Output
name  | dept        | salary | dept_avg
------+-------------+--------+----------
Ada   | Engineering | 120000 | 115000.0
Grace | Engineering | 110000 | 115000.0
Linus | Sales       |  90000 |  90000.0
(3 rows)

Why this works: PARTITION BY dept groups the underlying rows exactly like GROUP BY would, but a window function computes its aggregate per partition and then attaches that value to every original row in the partition, rather than replacing the rows with one summary row per group. Ada and Grace both see 115000.0 because they share the Engineering partition, while Linus sees his own salary as the Sales partition's average since he is its only member — all three original employee rows survive.

Reaching for GROUP BY when the goal is "this row plus its group's aggregate"

Wrong

sql
SELECT dept, avg(salary) FROM employees GROUP BY dept;
-- loses every individual employee row -- cannot show "Ada, 120000, dept avg 115000" in one row

Better

sql
SELECT name, dept, salary, avg(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
-- every employee row survives, each annotated with its department average

What you see: A report needs to show each individual row alongside a group-level aggregate (e.g. "this employee's salary vs their department's average"), and GROUP BY makes that structurally impossible without a self-join back to the ungrouped table.

Why: GROUP BY is defined to produce exactly one output row per distinct group, which is precisely why non-aggregated, non-grouped columns like an individual employee's name cannot appear in the SELECT list at all — that information no longer has a single row to belong to. A window function was designed for exactly this shape of question, computing the aggregate without ever collapsing the underlying rows.

GROUP BY collapses rows; a window function keeps them

GROUP BY dept

  • +one output row per department
  • +individual employee rows are gone
  • +SELECT dept, avg(salary) FROM employees GROUP BY dept

avg(salary) OVER (PARTITION BY dept)

  • one output row per employee, unchanged
  • each row also shows its department's average
  • nothing is collapsed
  • GROUP BY dept
    • one output row per department
    • individual employee rows are gone
    • SELECT dept, avg(salary) FROM employees GROUP BY dept
  • avg(salary) OVER (PARTITION BY dept)
    • one output row per employee, unchanged
    • each row also shows its department's average
    • nothing is collapsed

GROUP BY vs a window function, side by side

GROUP BY vs a window function, side by side
PropertyGROUP BYWindow function (OVER)
Rows in outputone per groupone per input row
Non-aggregated columnsmust appear in GROUP BYfreely available
Typical use"total per category""this row's value, plus its category's total"

Together

sql
SELECT name, dept, salary, avg(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;

Remember: OVER() computes across a set of related rows without collapsing them — PARTITION BY groups rows the same way GROUP BY would, but every original row survives in the output.

See also: ranking and offset functions · window frames

Advertisement

The function family

Ranking, offset and value functions, and the practical recipes built from them.

ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD and Value Functions

coreintermediate

ROW_NUMBER gives every row a unique sequential number. RANK and DENSE_RANK number rows by their ORDER BY value, handling ties differently: RANK leaves gaps after a tie, DENSE_RANK does not. LAG/LEAD read a value from a preceding/following row. FIRST_VALUE/LAST_VALUE/NTH_VALUE pull a value from a specific position in the window.

Think of it as

These are all "where does this row sit relative to its neighbors" questions, differing only in what exactly they report. ROW_NUMBER answers "what position, ignoring ties." RANK/DENSE_RANK answer "what position, respecting ties" (differing only in whether they leave gaps). LAG/LEAD answer "what was N rows before/after me." FIRST_VALUE/LAST_VALUE/NTH_VALUE answer "what value sits at a fixed position in my window."

sql
SELECT name, score,
    ROW_NUMBER() OVER (ORDER BY score DESC) AS rn,
    RANK() OVER (ORDER BY score DESC) AS rnk,
    LAG(score) OVER (ORDER BY score DESC) AS prev_score
FROM results;

What we're doing: Compare ROW_NUMBER, RANK and DENSE_RANK on data with a tie, then use LAG to see the previous row's score.

ranking_demo.sqlsql
CREATE TABLE results (name TEXT, score INT);

INSERT INTO results (name, score) VALUES
    ('Ada', 95), ('Grace', 90), ('Linus', 90), ('Margaret', 80);

SELECT name, score,
    ROW_NUMBER() OVER (ORDER BY score DESC) AS rn,
    RANK() OVER (ORDER BY score DESC) AS rnk,
    DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rnk,
    LAG(score) OVER (ORDER BY score DESC) AS prev_score
FROM results;
4
Grace and Linus are tied at 90 — the interesting case for RANK vs DENSE_RANK.
8–9
RANK gives the tied pair both rank 2, then skips to 4 for Margaret; ROW_NUMBER never ties, giving 2 and 3.
10
DENSE_RANK gives the tied pair both rank 2, then continues at 3 with no gap.
11
LAG(score) shows each row's predecessor in the ORDER BY sequence — NULL for the first row, since it has no predecessor.
Output
name     | score | rn | rnk | dense_rnk | prev_score
---------+-------+----+-----+-----------+-----------
Ada      |    95 |  1 |   1 |         1 |           
Grace    |    90 |  2 |   2 |         2 |         95
Linus    |    90 |  3 |   2 |         2 |         90
Margaret |    80 |  4 |   4 |         3 |         90

Why this works: RANK() assigns the same rank to tied rows but still counts them individually toward the next rank, so two rows tied at rank 2 push the following row to rank 4 (2 tied rows occupy ranks 2 AND 3, leaving 4 as the next number) — DENSE_RANK() counts only distinct rank values, so the same tie is followed immediately by rank 3. LAG(score) with no explicit offset always means "the immediately preceding row in this ORDER BY," which is why Ada's row shows NULL: there is no row before the first one.

Using RANK() where DENSE_RANK() (or vice versa) was actually intended

Wrong

sql
SELECT name, RANK() OVER (ORDER BY score DESC) AS position FROM results;
-- "position 4" after a two-way tie for 2nd -- if the intent was "4 distinct scoring tiers," this is wrong

Better

sql
SELECT name, DENSE_RANK() OVER (ORDER BY score DESC) AS tier FROM results;
-- "tier 3" for Margaret -- correctly reflects 3 distinct score values, not 4 row positions

What you see: A "top N distinct values" or "how many tiers exist" computation comes out too high, because RANK's gap-after-ties behavior counts tied rows individually even though the intent was to count distinct values.

Why: RANK() and DENSE_RANK() encode two different, equally valid interpretations of "position among ties" — RANK() answers "how many rows come before or tie with me," which necessarily leaves gaps, while DENSE_RANK() answers "how many distinct values come before or equal mine," which does not. Neither is a bug; picking the wrong one for the question actually being asked is the mistake, and it is worth deciding explicitly rather than defaulting to whichever function comes to mind first.

Four families of window function, one question each

ROW_NUMBER

position, no ties

RANK / DENSE_RANK

position, ties respected

LAG / LEAD

a neighboring row's value

FIRST/LAST/NTH_VALUE

a fixed position's value

  1. ROW_NUMBER — position, no ties
  2. RANK / DENSE_RANK — position, ties respected
  3. LAG / LEAD — a neighboring row's value
  4. FIRST/LAST/NTH_VALUE — a fixed position's value

How each function handles the same tied values

How each function handles the same tied values
Rank bySequence for values 90, 90, 80
ROW_NUMBER()1, 2, 3 — ties broken arbitrarily
RANK()1, 1, 3 — gap after the tie
DENSE_RANK()1, 1, 2 — no gap

Together

sql
SELECT name, score,
    RANK() OVER (ORDER BY score DESC) AS rnk,
    DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rnk
FROM results;

Remember: ROW_NUMBER never ties; RANK leaves gaps after ties; DENSE_RANK does not — LAG/LEAD read a neighboring row's value and return NULL when no such row exists in the partition.

See also: over partition by and order by · running totals and top n patterns

Running Totals, Moving Averages and Top-N-Per-Group

standardintermediate

A running total is SUM() OVER (ORDER BY ...) — each row adds itself to everything before it. A top-N-per-group pattern uses ROW_NUMBER() OVER (PARTITION BY group ORDER BY rank_col) inside a subquery or CTE, then filters WHERE row_number <= N in the outer query.

Think of it as

These are not new functions — they are the same OVER()/PARTITION BY/ORDER BY mechanism from the rest of this section, applied to answer a specific practical question. A running total is just SUM() with ORDER BY instead of no ORDER BY (which changes the default frame from "the whole partition" to "everything up to and including this row"). Top-N-per-group is just ROW_NUMBER() per partition, then a plain WHERE filter in an outer query — window functions cannot be filtered directly in the same SELECT's WHERE clause.

sql
-- running total
SELECT date, amount, SUM(amount) OVER (ORDER BY date) AS running_total
FROM payments;

-- top 2 earners per department
WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT name, dept, salary FROM ranked WHERE rn <= 2;

What we're doing: Compute a running total of payments over time, then separately find the top 2 highest-paid employees per department.

running_and_topn.sqlsql
CREATE TABLE payments (pay_date DATE, amount NUMERIC);
INSERT INTO payments VALUES ('2026-01-01', 100), ('2026-01-02', 50), ('2026-01-03', 75);

SELECT pay_date, amount, SUM(amount) OVER (ORDER BY pay_date) AS running_total
FROM payments;

CREATE TABLE employees (name TEXT, dept TEXT, salary NUMERIC);
INSERT INTO employees VALUES
    ('Ada', 'Eng', 150000), ('Grace', 'Eng', 140000), ('Linus', 'Eng', 130000),
    ('Margaret', 'Sales', 90000);

WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT name, dept, salary FROM ranked WHERE rn <= 2;
4
SUM with ORDER BY (no PARTITION BY) defaults to a frame of "everything from the start up to this row" — a running total.
12–15
ROW_NUMBER inside a CTE assigns per-department rank; the outer query filters to rn <= 2, since a window function cannot be filtered directly in its own SELECT's WHERE.
Output
pay_date   | amount | running_total
-----------+--------+---------------
2026-01-01 |    100 |           100
2026-01-02 |     50 |           150
2026-01-03 |     75 |           225
(3 rows)

name  | dept | salary
------+------+--------
Ada   | Eng  | 150000
Grace | Eng  | 140000
(2 rows)

Why this works: SUM() OVER (ORDER BY pay_date) with no explicit frame defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — every row sums itself plus every row before it in the ordering, which is exactly the definition of a running total. Linus and Margaret are excluded from the top-N result not because of a WHERE clause on salary directly, but because rn is computed per department first (Linus ranks 3rd in Engineering, Margaret ranks 1st in Sales but Sales only appears once in the source data) — the CTE is required specifically because WHERE cannot see a window function's result computed in the same SELECT.

Trying to filter a window function's result directly in the same query's WHERE clause

Wrong

sql
SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
WHERE rn <= 2;
-- ERROR: column "rn" does not exist

Better

sql
WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT * FROM ranked WHERE rn <= 2;

What you see: A query referencing a window function's alias in its own WHERE clause fails immediately with "column does not exist," even though the alias is right there in the SELECT list.

Why: SQL evaluates WHERE before SELECT conceptually (and before window functions, which run after WHERE/GROUP BY/HAVING in the logical processing order) — so at the point WHERE is evaluated, rn has not been computed yet and no such column exists. Wrapping the window function in a CTE or subquery gives it a chance to actually materialize in an intermediate step, and the outer query's WHERE then filters that intermediate result, where rn genuinely exists as a column.

Pattern → the window function shape that produces it

Pattern → the window function shape that produces it
GoalShape
Running totalSUM(col) OVER (ORDER BY date)
Running total per groupSUM(col) OVER (PARTITION BY grp ORDER BY date)
Top 2 per groupROW_NUMBER() OVER (PARTITION BY grp ORDER BY val DESC) in a CTE, then WHERE rn <= 2

Together

sql
WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT * FROM ranked WHERE rn <= 2;

Remember: A running total is just SUM() with ORDER BY instead of no ORDER BY; top-N-per-group is ROW_NUMBER() partitioned by group, filtered in an outer query — window functions cannot be filtered in their own SELECT's WHERE clause.

See also: ranking and offset functions · window frames

Advertisement

Frames and alternatives

Exactly which rows a window function considers, and when to reach for one over a self join.

Window Frames: ROWS and RANGE Conceptually

standardadvanced

A frame is the subset of rows within a partition that a window function actually looks at for the current row — by default, everything from the partition's start up to the current row (when ORDER BY is present). ROWS BETWEEN N PRECEDING AND CURRENT ROW narrows that to a fixed-size window, which is how a true moving average is built.

Think of it as

PARTITION BY decides the group; the frame decides which subset of that group a specific row's calculation actually uses. Without an explicit frame, ORDER BY implies "from the start of the partition through the current row" — which is exactly why SUM() OVER (ORDER BY date) produces a running total by default. Specifying ROWS BETWEEN 2 PRECEDING AND CURRENT ROW instead fixes the window to exactly 3 rows, which is what turns a running total into a genuine moving average.

sql
SELECT date, amount,
    AVG(amount) OVER (
        ORDER BY date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg_3day
FROM payments;

What we're doing: Compute a 3-row moving average with an explicit ROWS frame, and compare it against the default RANGE-based running total.

window_frames.sqlsql
CREATE TABLE payments (pay_date DATE, amount NUMERIC);
INSERT INTO payments VALUES
    ('2026-01-01', 100), ('2026-01-02', 50), ('2026-01-03', 75), ('2026-01-04', 200);

SELECT pay_date, amount,
    SUM(amount) OVER (ORDER BY pay_date) AS running_total,
    AVG(amount) OVER (
        ORDER BY pay_date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg_3day
FROM payments;
5
Default frame (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW): every row sums itself plus every prior row.
7–10
Explicit ROWS BETWEEN 2 PRECEDING AND CURRENT ROW: exactly 3 physical rows (or fewer near the start), never the whole history.
Output
pay_date   | amount | running_total | moving_avg_3day
-----------+--------+---------------+----------------
2026-01-01 |    100 |           100 |          100.0
2026-01-02 |     50 |           150 |           75.0
2026-01-03 |     75 |           225 |           75.0
2026-01-04 |    200 |           425 |          108.3
(3 rows)

Why this works: running_total keeps growing across all 4 rows because its frame is unbounded on the preceding side, so the 4th row still includes the 1st row's amount. moving_avg_3day on the 4th row averages only rows 2, 3 and 4 (50, 75, 200), because ROWS BETWEEN 2 PRECEDING AND CURRENT ROW explicitly caps the frame at 3 physical rows regardless of how much history exists — the 1st row has already aged out of the window by the time the 4th row is processed.

Assuming RANGE and ROWS produce identical results whenever ORDER BY has no ties

Wrong

sql
-- assumed equivalent to a 3-row ROWS frame
SUM(amount) OVER (ORDER BY pay_date RANGE BETWEEN 2 PRECEDING AND CURRENT ROW)
-- RANGE with a numeric/date ORDER BY interprets "2 PRECEDING" as a VALUE offset, not a row count

Better

sql
SUM(amount) OVER (ORDER BY pay_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- ROWS unambiguously means "2 physical rows before this one"

What you see: A frame intended as "the last 3 rows" silently includes a different number of rows than expected once the ORDER BY column has gaps or duplicate values, because RANGE with an offset interprets the boundary as a value distance, not a row count.

Why: RANGE BETWEEN N PRECEDING AND CURRENT ROW (with a numeric/date offset, as opposed to the default UNBOUNDED form) defines its boundary in terms of the ORDER BY column's actual values, so "2 PRECEDING" on a date column means "dates within 2 days," which can include a different row count than a fixed "2 rows before this one." ROWS is the unambiguous choice whenever the intent is genuinely "N physical rows," which is what most moving-average use cases actually mean.

ROWS vs RANGE — how they treat tied ORDER BY values

ROWS vs RANGE — how they treat tied ORDER BY values
Frame modeBoundary counted byBehavior on ties
ROWSphysical row positioneach row is its own boundary, regardless of ties
RANGEORDER BY valuetied rows share the same frame boundary

Together

sql
SELECT date, amount,
    AVG(amount) OVER (ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg
FROM payments;

Remember: The default frame (with ORDER BY) is "start of partition through current row" — that is why plain SUM() OVER (ORDER BY ...) is a running total. Use an explicit ROWS BETWEEN N PRECEDING AND CURRENT ROW frame for a true fixed-size moving window.

See also: over partition by and order by · running totals and top n patterns

When Window Functions Beat Self Joins or Nested Queries

standardintermediate

Anything answerable with a window function can usually also be written as a self join or a correlated subquery — but the window function version is typically both clearer to read and a single pass over the data, while the self join/subquery version often reprocesses the same rows once per outer row.

Think of it as

A self join or correlated subquery re-derives the "related rows" relationship from scratch for every outer row, using join predicates or a WHERE clause. A window function is told the relationship once, via PARTITION BY, and the engine computes everything in a single coordinated pass — the same underlying question, but the window function version does not require re-describing the relationship as a join condition.

sql
-- window function version of the same question
SELECT name, salary, MAX(salary) OVER (PARTITION BY dept) AS dept_max
FROM employees;

What we're doing: Write "each employee vs their department's highest salary" both as a self join and as a window function, and compare the queries.

window_vs_selfjoin.sqlsql
CREATE TABLE employees (name TEXT, dept TEXT, salary NUMERIC);
INSERT INTO employees VALUES
    ('Ada', 'Eng', 150000), ('Grace', 'Eng', 140000), ('Linus', 'Sales', 90000);

-- self join / subquery version
SELECT e.name, e.salary, d.max_salary
FROM employees e
JOIN (
    SELECT dept, max(salary) AS max_salary FROM employees GROUP BY dept
) d ON d.dept = e.dept;

-- window function version -- same result, no join, one pass
SELECT name, salary, MAX(salary) OVER (PARTITION BY dept) AS max_salary
FROM employees;
5–9
The self join needs a derived table (GROUP BY dept), then an explicit JOIN back to employees on dept.
12–13
The window function expresses the same relationship with PARTITION BY dept — no derived table, no explicit join.
Output
name  | salary | max_salary
------+--------+-----------
Ada   | 150000 |     150000
Grace | 140000 |     150000
Linus |  90000 |      90000
(3 rows)

Why this works: Both queries produce the identical result set because they express the same underlying relationship — "compare this row to an aggregate of its own group" — but the self join version has to physically materialize a derived table and join it back, while the window function version lets the engine compute the per-partition aggregate in a single pass and attach it directly to each row. This is why the window function version is both shorter to write and typically the plan the optimizer favors for this exact shape of question.

Reaching for a self join out of habit for a "compare to my group" question

Wrong

sql
SELECT e.name, e.salary, d.max_salary
FROM employees e
JOIN (SELECT dept, max(salary) AS max_salary FROM employees GROUP BY dept) d
    ON d.dept = e.dept;

Better

sql
SELECT name, salary, MAX(salary) OVER (PARTITION BY dept) AS max_salary
FROM employees;

What you see: A query answering a simple "this row vs its group's aggregate" question grows an extra derived table and JOIN clause that adds no real capability, just extra SQL to read and maintain.

Why: A self join back to a GROUP BY subquery is solving a more general problem than "this row vs its own group's aggregate" needs — that general join machinery is necessary when genuinely comparing two independently-filterable row shapes, but PARTITION BY already expresses "group rows by this column" without needing a join at all when the comparison is just to an aggregate of the same table. The habit of reaching for a join is often inherited from older SQL or other engines with weaker window function support, not a reflection of PostgreSQL's own capabilities.

Same question, two approaches

Same question, two approaches
ApproachShape
Self joinjoin employees to a GROUP BY subquery of itself, matching on dept
Window functionMAX(salary) OVER (PARTITION BY dept) — one pass, no join

Together

sql
-- self join version
SELECT e.name, e.salary, d.max_salary
FROM employees e
JOIN (SELECT dept, max(salary) AS max_salary FROM employees GROUP BY dept) d
    ON d.dept = e.dept;

Remember: A window function expresses "this row vs its own group" in one pass, no join required — reach for a self join only when the comparison genuinely needs two independently-filterable row shapes side by side.

See also: over partition by and order by · cross and self joins

Advertisement