Partitioning big tables
When one table holds years of sales, every query, vacuum and clean-up gets slower. Partitioning splits that table into smaller tables behind one name, so Postgres reads only the months a query needs and you can remove an old month in about a second.
By the end you can
- Explain how partitioning differs from an index, in two sentences
- Say exactly how Postgres knows which partition a new row or a query belongs to
- Write the SQL for monthly range partitions on
sale, with a default partition and an automatic job for new months - Retire an old month with DETACH and DROP instead of a huge DELETE
- Spot the common traps: the key in the primary key, queries without the key, too many partitions
The everyday picture
Think of a 30-volume encyclopedia in a school library. Volume 1 covers A to B, volume 2 covers C to D, and so on. The spine of each book shows its range. If you want "Rice", you do not open thirty books. You read the spines, take the volume for R, and ignore the rest.
Inside that volume there is an index at the back. It tells you the page number for "Rice". So the encyclopedia uses two tools at once. The split into volumes tells you which book. The index tells you which page inside that book.
Now the publisher prints a new edition of volume 1. The librarian takes the old volume 1 off the shelf and puts the new one in. Nobody erases the old pages one by one. Postgres partitioning gives you the same three things: the split, an index inside each piece, and a quick way to remove a whole piece.
Partitioning and indexing are different tools
A common first question is "Isn't partitioning the same as indexing?" Here is the short answer. An index is a separate sorted structure that points to rows, while the rows stay in one big table (Indexes from the inside). Partitioning moves the rows themselves into several smaller tables, and each of those tables gets its own indexes.
"If a table is split into three, does that mean three tables?" Yes. In Postgres each partition is a real table with its own file on disk. You also get one more object, the parent table. The parent stores zero rows. It holds the rule, called the partition key, plus the list of partitions and the value range each one accepts. So "split into three" means four entries in the catalogue: one parent and three partitions.
"How does the API or query know which table to use?" It does not need to know. Your NestJS code, Prisma and the reporting tool always use the parent name, sale. Postgres does the rest in two places:
- On INSERT, Postgres reads the row's
sale_date, compares it with the stored ranges, and writes the row into the matching partition. This is called tuple routing. If an UPDATE changessale_dateto another month, Postgres moves the row for you (since PG 11). - On SELECT, the planner compares your WHERE clause with the same ranges and drops every partition that cannot contain a match. This is partition pruning. A query for September reads only the September table.
The two tools work together. Pruning picks the partition, and that partition's own B-tree index finds the rows inside it.
| Index | Partitioning | |
|---|---|---|
| What it is | A sorted list of keys pointing to rows | The rows split into separate tables by a rule |
| Where rows live | One big table | Many smaller tables behind one parent |
| Speeds up | Finding a few rows by a column | Queries that filter on the key; maintenance per piece |
| Removing old data | No help; DELETE still touches every row | Drop a whole month in about a second |
| Cost | Slower writes, disk space | More objects to manage, rules on keys |
Quick check
You partition sale into 12 monthly partitions. What does Postgres now hold?
The parent stores zero rows. It keeps the partition key and the range of each partition, and your code keeps using its name. Each month is a real table with its own file. Nothing is stored twice, and an index never moves rows into new tables.
sale. The parent holds the rules, not the rows. A sale dated 14 September is written into sale_2026_09. A date with no matching month lands in the default partition.Pruning: how a query skips most of the table
Each partition stores its range in the catalogue, for example "from 2026-09-01 up to, but not including, 2026-10-01". When a query arrives, the planner checks every range against the WHERE clause. A filter like sale_date >= '2026-09-01' AND sale_date < '2026-10-01' overlaps one range, so only one partition stays in the plan.
Pruning can happen at two moments. If the values are written in the SQL, the planner prunes while it builds the plan. If the values arrive later, as parameters (Prisma always sends $1, $2) or from now(), Postgres prunes when the query runs. In EXPLAIN that second kind shows up as a line like Subplans Removed: 4.
Pruning compares the key column itself with the ranges. If you wrap the key in a function, such as date_trunc('month', sale_date) or extract(month FROM sale_date), the planner can no longer match it against the ranges, and it reads every partition. The rows that come back are the same, so nobody notices until the table is big. Write date filters as a half-open range on the raw column: >= the first day, < the first day of the next period.
The catch is in the word "key". Pruning only works for filters on the partition key. A query with WHERE site_id = 42 and no date has to visit every partition. Each partition's own index still helps, so it is many small index lookups instead of one, but the planner has more work and you lose most of the benefit.
Quick check
sale is partitioned by month on sale_date. Which filter lets the planner read only September's partition?
Only the plain range on the key can be compared with each partition's bounds. The two function versions read every partition, even though the first returns the same rows. The extract version is also wrong: it matches September of every year.
site_id is not the partition key, so Postgres must look inside every partition.Try it yourself. Pick a month range and watch how many partitions are read and how many rows the planner has to consider. Then switch the filter to "By site_id only". At the bottom, compare deleting a year of old data with dropping whole partitions.
- A two-month date filter reads 2 of 12 partitions. The other ten cost nothing.
- The delete comparison is the bigger win in real life: dropping a partition writes almost nothing, while a DELETE touches every row.
Think first: the Store app has a "find receipt by number" screen that sends WHERE id = 98765. What happens on a table partitioned by month?
There is no date in the filter, so nothing can be pruned. Postgres checks the primary key index of every partition. With 36 monthly partitions that is 36 small index lookups instead of one. Each lookup is fast, so the screen may still feel fine, but the cost grows with every month you keep. The cleaner fix is a product one: the printed receipt already shows its date, so let the screen send the date too. Then the query reads one partition.
Range, list and hash
Postgres offers three kinds of rule. Pick the one that matches how the data is read and how it is removed.
- Range: each partition takes a span of values, usually time. Use it for sales, goods received note (GRN) lines, handheld scanner events and logs. Almost every query has a date filter, and old data leaves by month. This is the common case.
- List: each partition takes named values, for example
FOR VALUES IN ('north', 'north-east'). Useful when queries always target one region or one business unit. Watch out for skew: if 60% of your stores are in the capital city, that partition stays huge. - Hash: Postgres hashes the key and spreads rows evenly, for example
FOR VALUES WITH (MODULUS 8, REMAINDER 3). Use it when there is no natural range and you want smaller, even pieces for vacuum and index size. It does not help you remove old data.
Partition ranges do not need equal sizes. A festival month (Eid, Diwali or Christmas, depending on where you are) can be twice the size of a quiet month, and that is fine.
Quick check
The Rider app stores GPS pings. Every query asks for a time window, and pings are deleted after 30 days. Which rule fits?
Queries filter by time and old data leaves by time, so range partitions give you both pruning and one-step deletes: each night, drop the day that just turned 31 days old. Hash spreads rows evenly but never lets you drop old data in one piece. List by city helps neither the time filter nor retention.
The real win: removing old data in one second
Say you keep two years of sales and the oldest month holds about 50 million rows. A DELETE ... WHERE sale_date < ... visits all 50 million rows. It writes a change record for each one to the WAL, so replicas and backups have to process all of it. It leaves 50 million dead rows that vacuum must clean later (Running Postgres in production), and the indexes stay bloated. It can run for hours.
With partitions you remove the month as a whole table. DETACH PARTITION unhooks it from the parent so queries no longer see it. Then you can archive it with pg_dump and DROP TABLE it. Dropping deletes the table's files. It writes almost no WAL and leaves nothing for vacuum.
Locks matter here, as they did in Changing the schema without downtime. A plain DETACH PARTITION takes an ACCESS EXCLUSIVE lock on the parent for a moment. It is quick, but it waits behind any long query and blocks everyone behind it. Since PG 14 you can write DETACH PARTITION ... CONCURRENTLY, which takes a lighter lock that lets reads and writes continue. It has two rules: it cannot run inside a transaction block, and it refuses to run while the table has a default partition.
Adding a partition has a lock too. CREATE TABLE ... PARTITION OF sale takes ACCESS EXCLUSIVE on sale. So create future months ahead of time, during quiet hours, with a lock_timeout. Never create them at midnight on the first of the month while the POS is busy. If even that short lock hurts, create the month as a normal table with a matching CHECK constraint and then ATTACH PARTITION it. ATTACH takes a lighter lock on the parent.
The traps
- The primary key must include the partition key. Each partition checks uniqueness only inside itself, so Postgres insists that unique constraints contain
sale_date. The key becomes(id, sale_date). Any table with a foreign key tosale, such assale_line, must store both columns. - Queries without the key read every partition. Check your hot screens and API calls. If they do not filter on the key, partitioning will not speed them up.
- Too many partitions slow planning. The PostgreSQL docs say the planner handles up to a few thousand partitions fairly well when queries prune most of them. Monthly partitions for five years are 60 tables, which is easy. Daily partitions for five years are over 1,800, so measure before you choose that.
- The default partition can trap you. It catches rows with no matching month, so inserts never fail. But if it holds even one row for November, creating
sale_2026_11later fails with an error. Adding any partition also scans the default under a strong lock. Keep it empty and alert when it is not. - Partitioning is not sharding. All partitions live on the same server, with the same CPU, RAM and disk. It makes big tables manageable. It does not give you more write capacity. That is Sharding and consistent hashing.
Build it
We will partition sale by month, watch Postgres route and prune, automate new months, retire an old month, and map it in Prisma. Run this on a test database with PostgreSQL 16 or newer. Two of the steps, routing rows into partitions and making a report prune, are also in the Practice section below, where they run in your browser.
-
Create the partitioned parent
The parent declares the columns, the rule and the primary key. It will never hold rows itself.
SQLCREATE TABLE sale ( id bigserial, -- what Prisma generates for autoincrement() site_id bigint NOT NULL, sale_date date NOT NULL, -- the partition key sold_at timestamptz(6) NOT NULL DEFAULT now(), total numeric(12,2) NOT NULL, PRIMARY KEY (id, sale_date) -- must include the partition key ) PARTITION BY RANGE (sale_date); -- Without sale_date in the key, Postgres refuses: -- ERROR: unique constraint on partitioned table must include all partitioning columns -
Create monthly partitions, a default and one index
The lower bound is included and the upper bound is not, so months never overlap. An index created on the parent is created on every partition, now and in the future.
SQLCREATE TABLE sale_2026_08 PARTITION OF sale FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); CREATE TABLE sale_2026_09 PARTITION OF sale FOR VALUES FROM ('2026-09-01') TO ('2026-10-01'); CREATE TABLE sale_2026_10 PARTITION OF sale FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); CREATE TABLE sale_default PARTITION OF sale DEFAULT; -- catches any other date CREATE INDEX sale_site_id_sale_date_idx ON sale (site_id, sale_date);On a big table that already has partitions,
CREATE INDEX CONCURRENTLYon the parent is not allowed. Create the parent index withON ONLY sale, build each partition's index concurrently, then attach each one withALTER INDEX ... ATTACH PARTITION. -
Load test rows and see where they went
tableoid::regclasstells you which physical table each row lives in.SQLINSERT INTO sale (site_id, sale_date, total) SELECT (g % 400) + 1, date '2026-08-01' + (g % 92), (g % 900) + 50 FROM generate_series(1, 300000) AS g; ANALYZE sale; SELECT tableoid::regclass AS partition, count(*) AS rows FROM sale GROUP BY 1 ORDER BY 1;Outputpartition | rows --------------+-------- sale_2026_08 | 101090 sale_2026_09 | 97830 sale_2026_10 | 101080 -
Prove pruning with EXPLAIN
A date filter reads one partition. A
site_idfilter alone reads all of them, each through its own index.SQLEXPLAIN (COSTS OFF) SELECT site_id, sum(total) FROM sale WHERE sale_date >= '2026-09-01' AND sale_date < '2026-10-01' GROUP BY site_id; EXPLAIN (COSTS OFF) SELECT sum(total) FROM sale WHERE site_id = 42;OutputHashAggregate Group Key: sale.site_id -> Seq Scan on sale_2026_09 sale <- only September Filter: ((sale_date >= '2026-09-01'::date) AND (sale_date < '2026-10-01'::date)) Aggregate -> Append <- every partition -> Bitmap Heap Scan on sale_2026_08 sale_1 -> Bitmap Index Scan on sale_2026_08_site_id_sale_date_idx -> Bitmap Heap Scan on sale_2026_09 sale_2 -> Bitmap Index Scan on sale_2026_09_site_id_sale_date_idx -> Bitmap Heap Scan on sale_2026_10 sale_3 -> Bitmap Index Scan on sale_2026_10_site_id_sale_date_idx -> Seq Scan on sale_default sale_4 -
Create future months automatically
Create partitions a few months ahead so inserts never hit a missing month. Use a small function run by a daily job, or the pg_partman extension.
SQLCREATE OR REPLACE FUNCTION ensure_sale_partition(p_month date) RETURNS text LANGUAGE plpgsql SET lock_timeout = '5s' -- fail fast instead of queueing behind a long query AS $$ DECLARE v_from date := date_trunc('month', p_month)::date; v_to date := (date_trunc('month', p_month) + interval '1 month')::date; v_name text := 'sale_' || to_char(v_from, 'YYYY_MM'); BEGIN IF to_regclass(v_name) IS NULL THEN EXECUTE format('CREATE TABLE %I PARTITION OF sale FOR VALUES FROM (%L) TO (%L)', v_name, v_from, v_to); RETURN 'created ' || v_name; END IF; RETURN 'exists ' || v_name; END $$; -- Keep this month and the next 3 ready SELECT ensure_sale_partition((date_trunc('month', current_date) + make_interval(months => m))::date) FROM generate_series(0, 3) AS m; -- Run it every night at 03:15 with the pg_cron extension SELECT cron.schedule('sale-partitions', '15 3 * * *', $$SELECT ensure_sale_partition((date_trunc('month', current_date) + make_interval(months => m))::date) FROM generate_series(0, 3) AS m$$);SQL-- pg_partman 5.x: manages future partitions and retention for you. -- Point it at the empty parent from step 1 (skip step 2's manual -- partitions); it creates the child tables and a default itself. CREATE SCHEMA partman; CREATE EXTENSION pg_partman SCHEMA partman; SELECT partman.create_parent( p_parent_table := 'public.sale', -- must be schema-qualified p_control := 'sale_date', p_interval := '1 month', p_premake := 3 -- keep 3 future months ready ); -- Keep 25 months; detach older ones but keep them as normal tables UPDATE partman.part_config SET retention = '25 months', retention_keep_table = true WHERE parent_table = 'public.sale'; -- Run maintenance from cron, or set the pg_partman_bgw.* settings CALL partman.run_maintenance_proc(); -
Retire an old month
In this test, treat
sale_2026_08as the month that has aged out. Because we kept a default partition, the CONCURRENTLY form is refused, so we use a plain DETACH with a short lock timeout.SQL-- With a default partition, CONCURRENTLY is refused: -- ERROR: cannot detach partitions concurrently when a default partition exists SET lock_timeout = '3s'; -- give up rather than block the POS ALTER TABLE sale DETACH PARTITION sale_2026_08; -- Without a default partition (PG 14+), use the lighter lock instead. -- Run it on its own, never inside BEGIN ... COMMIT: -- ALTER TABLE sale DETACH PARTITION sale_2026_08 CONCURRENTLY; -- If that was interrupted, finish it with: -- ALTER TABLE sale DETACH PARTITION sale_2026_08 FINALIZE;Terminal# Archive the detached month, check the file, then drop it pg_dump -Fc -t sale_2026_08 -d mangrove -f sale_2026_08.dump pg_restore -l sale_2026_08.dump | grep "TABLE DATA" psql -d mangrove -c "DROP TABLE sale_2026_08;" -
Map it in Prisma
Prisma has no syntax for partitioning (as of Prisma 6). The model maps to the parent table, and the partitioning lives in raw SQL inside the migration. Generate the migration with
npx prisma migrate dev --create-only --name partition_sale, addPARTITION BY RANGE ("sale_date")to the generatedCREATE TABLE, add the partitions below it, then runnpx prisma migrate dev.Prismamodel Sale { id BigInt @default(autoincrement()) siteId BigInt @map("site_id") saleDate DateTime @map("sale_date") @db.Date soldAt DateTime @default(now()) @map("sold_at") @db.Timestamptz(6) total Decimal @db.Decimal(12, 2) @@id([id, saleDate]) // composite key, includes the partition key @@index([siteId, saleDate]) @@map("sale") }TypeScript// Always pass the date range so Postgres can prune const rows = await prisma.sale.findMany({ where: { siteId: 42, saleDate: { gte: new Date('2026-09-01'), lt: new Date('2026-10-01') }, }, }); // Lookups by id now need both parts of the key const one = await prisma.sale.findUnique({ where: { id_saleDate: { id: 98765n, saleDate: new Date('2026-09-14') } }, });Since Prisma 4.10, introspection ignores the child partitions and shows only the parent as a model, so
migrate devshould not try to drop them. Old versions did, so test once: runmigrate devagainst a copy and read the SQL it generates before you trust it. Create future months with the job from step 5, not with Prisma migrations.
Bring it to your team
Use these when someone proposes partitioning, or when a big table keeps causing pain.
- "Which column do almost all queries on this table filter by? Is that our partition key?"
- "How big is the table today, how many rows a day does it grow, and how long must we keep them?"
- "What creates next month's partition, how far ahead, and what alerts us if the job did not run?"
- "How do we remove old data: by dropping partitions, or are we still planning a DELETE job?"
- "Do we have a default partition? Who gets alerted if a row lands in it?"
- "Does every report screen send a date range, and what is its default?"
Red flags
- A nightly
DELETE FROM sale WHERE sale_date < ...on a table with hundreds of millions of rows. - "Let's partition it to make it fast" for a table of a few GB that is missing an index.
- Daily partitions with years of retention and nobody has measured planning time.
- Hot API calls on the partitioned table that never include the partition key.
- "Partitioning will let one database take more writes." That needs sharding or a bigger server.
Practice
Route rows into monthly partitions
The parent table sale and its August 2026 partition already exist, with two rows in August. Create the September and October 2026 partitions and a default partition named sale_default. Keep the second part of the starter at the end: it inserts three sales and shows which partition each one landed in. Remember that the upper bound of a range is not included.
CREATE TABLE sale (
id int NOT NULL,
site_id int NOT NULL,
sale_date date NOT NULL,
total_cents int NOT NULL,
PRIMARY KEY (id, sale_date)
) PARTITION BY RANGE (sale_date);
CREATE TABLE sale_2026_08 PARTITION OF sale FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
INSERT INTO sale VALUES (1, 3, '2026-08-02', 1200), (2, 5, '2026-08-17', 450);
-- 1. Create sale_2026_09, sale_2026_10 and sale_default here.
-- 2. Keep this part: it inserts three sales and shows where they went.
INSERT INTO sale VALUES
(3, 3, '2026-09-14', 1250),
(4, 3, '2026-10-02', 800),
(5, 5, '2026-12-25', 4000);
SELECT tableoid::regclass::text AS partition, count(*) AS rows
FROM sale GROUP BY 1 ORDER BY 1;
[{"partition": "sale_2026_08", "rows": 2}, {"partition": "sale_2026_09", "rows": 1}, {"partition": "sale_2026_10", "rows": 1}, {"partition": "sale_default", "rows": 1}]
Show a solution
CREATE TABLE sale_2026_09 PARTITION OF sale FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE sale_2026_10 PARTITION OF sale FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE sale_default PARTITION OF sale DEFAULT;
INSERT INTO sale VALUES
(3, 3, '2026-09-14', 1250),
(4, 3, '2026-10-02', 800),
(5, 5, '2026-12-25', 4000);
SELECT tableoid::regclass::text AS partition, count(*) AS rows
FROM sale GROUP BY 1 ORDER BY 1;Why it works
Each PARTITION OF table takes the range from its lower bound up to, but not including, its upper bound, so the months meet without overlapping. The Christmas sale has no month of its own and lands in sale_default. Without the default, that INSERT would fail with "no partition of relation sale found for row". In a real system you would alert on that row and create December ahead of time.
Make a store report prune
Ama's store is site 3. Her September 2026 report reads every partition, because its filter wraps sale_date in a function. The setup has a helper, check_report(query), that runs EXPLAIN on your query, lists the partitions the plan reads, and also runs the query to show its result. Rewrite only the WHERE clause so the plan reads September's partition alone. The revenue must stay the same, so watch the last day of the month.
CREATE TABLE sale (
id int NOT NULL,
site_id int NOT NULL,
sale_date date NOT NULL,
total_cents int NOT NULL,
PRIMARY KEY (id, sale_date)
) PARTITION BY RANGE (sale_date);
CREATE TABLE sale_2026_08 PARTITION OF sale FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE sale_2026_09 PARTITION OF sale FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE sale_2026_10 PARTITION OF sale FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE sale_default PARTITION OF sale DEFAULT;
INSERT INTO sale VALUES
(1, 3, '2026-08-31', 900), (2, 3, '2026-09-01', 1250), (3, 5, '2026-09-10', 700),
(4, 3, '2026-09-18', 2300), (5, 3, '2026-09-30', 600), (6, 3, '2026-10-01', 1800),
(7, 5, '2026-10-12', 450), (8, 3, '2026-10-20', 990), (9, 3, '2025-09-15', 3000);
CREATE FUNCTION check_report(q text)
RETURNS TABLE (partitions_read text, revenue_cents bigint)
LANGUAGE plpgsql AS $f$
DECLARE
line text;
parts text[] := '{}';
BEGIN
FOR line IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP
IF line ~ ' on sale_' AND line !~ 'Bitmap Index Scan' THEN
parts := parts || substring(line from ' on (sale_[a-z0-9_]+)');
END IF;
END LOOP;
EXECUTE q INTO revenue_cents;
partitions_read := array_to_string(ARRAY(SELECT DISTINCT p FROM unnest(parts) AS p ORDER BY p), ', ');
RETURN NEXT;
END $f$;
-- Change only the WHERE clause inside $q$ ... $q$
SELECT * FROM check_report($q$
SELECT sum(total_cents) FROM sale
WHERE site_id = 3
AND date_trunc('month', sale_date) = '2026-09-01'
$q$);
[{"partitions_read": "sale_2026_09", "revenue_cents": 4150}]
Show a solution
SELECT * FROM check_report($q$
SELECT sum(total_cents) FROM sale
WHERE site_id = 3
AND sale_date >= '2026-09-01'
AND sale_date < '2026-10-01'
$q$);Why it works
The planner can compare a plain range on sale_date with each partition's bounds, so August, October and the default are pruned. The function version returns the same 4,150 cents but reads all four partitions. A common slip is BETWEEN '2026-09-01' AND '2026-10-01': it includes 1 October, reads two partitions and adds 1,800 cents that belong to October. The half-open range avoids that trap, and it also works when the column is a timestamp.
Check yourself
Table sale is partitioned by month on sale_date. Which table name should your NestJS service use in prisma.sale.findMany(...) for September?
The app always uses the parent. Postgres compares the date filter with each partition's range and reads only September. Mapping each partition as its own model means changing code every month, and a UNION view is what partitioning already does for you.
You must remove all sales older than 25 months from a 900 million row table partitioned by month. What is the best approach?
Dropping a partition removes its files at once, writes almost no WAL and leaves nothing for vacuum. Batched deletes are the right tool on an unpartitioned table, but here they still touch every row. VACUUM FULL rewrites the table under an ACCESS EXCLUSIVE lock, which blocks the POS.
Why does CREATE TABLE sale (id bigserial PRIMARY KEY, sale_date date NOT NULL) PARTITION BY RANGE (sale_date) fail?
Postgres enforces a unique key with one index per partition. Two partitions could each hold id 5 without knowing about each other, unless the key also contains the partition column. Dates and bigserial both work fine on partitioned tables.
Flashcards
Key takeaways
- An index finds rows inside one table. Partitioning splits the rows into several real tables behind one parent name. They work together.
- The app always uses the parent. Postgres routes inserts by the partition key and prunes partitions that cannot match the WHERE clause.
- Pruning needs a plain filter on the key, with no function around it. Make date ranges part of every report screen.
- The biggest win is maintenance: retire a month with DETACH and DROP instead of a giant DELETE.
- Remember the rules: the key goes into every unique constraint, create partitions ahead with a lock timeout, keep the default partition empty, and keep the partition count modest.