Sara has been running the same report for eighteen months. She opens her SQL client mid-morning,
launches the query for average basket by city and goes off for a coffee, because she knows it
takes between four and six minutes. What she did not know until module 5 —until the
mercadofresco-produccion dashboard showed it in a chart— is that during those minutes the shop's
latency rises, and that the complaints on the Friday she ran it at 18:30 were no coincidence.
The migration to Aurora has improved things: a custom endpoint isolates Sara's queries on
aurora-mf-lector-lotes and the shop no longer notices them. But the reports still take exactly as
long, because the problem was never one of isolation: it is that a row-oriented engine reads 48
million complete rows of 40 columns in order to use 4, uncompressed and unparallelised. Amazon
Redshift is AWS's data warehouse: a columnar, compressed, massively parallel engine designed for
precisely the question Sara asks every day. Here the reports leave the transactional database for
good, are modelled as a star schema and go from minutes to seconds.
Cost warning. Redshift is one of those services that generate a bill fastest if you are careless: a forgotten provisioned cluster costs hundreds of dollars a month. When you finish any test, delete the namespace and the workgroup.
Compliance warning. An analytical warehouse concentrates the complete purchase history of every customer in one place: in GDPR terms it is MercadoFresco's most sensitive asset. The anonymisation, the legal basis for analytical processing and the retention policy must be documented and reviewed by the data protection officer before the first load.
Contents
- OLAP versus OLTP: why a report kills a transactional database
- Columnar storage, compression and massively parallel execution
- Architecture: leader node, compute nodes and slices
- RA3, DC2 and Redshift Serverless
- MercadoFresco's star schema
- Distribution and sort keys
- Loading data with
COPYand the nightly pipeline - Zero-ETL integration from Aurora
- Redshift Spectrum, Athena and AWS Glue
- Sara's business queries
- Materialised views
- Concurrency, WLM queues and concurrency scaling
- Security, permissions and anonymisation
- Costs, pause and resume
- Common mistakes and tips
- Exercises
- Conclusion
OLAP versus OLTP: why a report kills a transactional database
In 06-01 we saw the table that separates the two worlds. Now it is time to see the concrete mechanism, with Sara's real query:
-- The average basket by city report, exactly as it stands today on Aurora.
SELECT c.ciudad,
COUNT(*) AS orders,
AVG(p.importe) AS average_basket,
SUM(p.importe) AS revenue
FROM pedidos p
JOIN clientes c ON c.id_cliente = p.id_cliente
WHERE p.fecha_pedido >= CURRENT_DATE - INTERVAL '18 months'
GROUP BY c.ciudad
ORDER BY revenue DESC;The pedidos table has 40 columns and 48 million rows, with an average row size of 380 bytes. The
query needs three: id_cliente, importe and fecha_pedido, about 28 bytes.
In a row-oriented engine the data is stored one complete row after another. To read 28 bytes you have to read all 380, because disk is read in blocks and every block holds whole rows:
| Aurora (rows) | Redshift (columns) | |
|---|---|---|
| Data read | 48 M × 380 B = 18.2 GB | 48 M × 28 B = 1.34 GB |
| Typical compression | None on the data | 3-4× → ≈380 MB |
| Parallelism | 1 process | Every slice at once |
| Measured duration | 4-6 min | 2-6 s |
The reduction is no trick: it is reading 48 times less data and spreading it over dozens of processes.
Columnar storage, compression and massively parallel execution
graph TB
subgraph FILAS["Row-oriented · Aurora"]
F1["Block 1: (84213, 4471, 2026-08-02, 34.20, Valencia, ...37 more columns)"]
F2["Block 2: (84214, 8802, 2026-08-02, 51.90, Bilbao, ...37 more columns)"]
end
subgraph COLS["Column-oriented · Redshift"]
C1["Block A · id_pedido: 84213, 84214, 84215, 84216, ..."]
C2["Block B · importe: 34.20, 51.90, 12.75, 88.40, ..."]
C3["Block C · fecha: 2026-08-02, 2026-08-02, 2026-08-02, ..."]
end
Q["SELECT AVG(importe) ..."] -->|reads whole blocks<br/>and discards 37 columns| FILAS
Q -->|reads only block B| COLS
Storing by columns has a second advantage that multiplies the first: every value in a block is of the same type and very similar to its neighbours, so it compresses extraordinarily well.
| Encoding | How it works | Good for | Example in MercadoFresco |
|---|---|---|---|
AZ64 |
Proprietary compression for numeric and date types | Numbers, dates | importe, fecha_pedido |
ZSTD |
General-purpose, high-ratio compression | Variable text | nombre_producto |
BYTEDICT |
Dictionary of up to 256 values | Low cardinality | estado, franja_reparto |
RUNLENGTH |
Stores value and repetitions | Repeated, sorted values | id_ciudad if it is the sort key |
RAW |
Uncompressed | Small sort keys | The first column of the key |
Redshift chooses the encoding by itself: with ENCODE AUTO —the default behaviour— it analyses the
data as it is loaded and applies whatever fits. The practical recommendation is to leave it on
automatic unless you have a measured reason to do otherwise; ANALYZE COMPRESSION shows what it
would recommend on data that is already loaded.
The third piece is MPP (massively parallel processing): the table does not live in one place, it is spread across every node and each one processes its share simultaneously. With 4 nodes of 4 slices, Sara's query splits into 16 jobs that each read and aggregate 3 million rows at once, and the partial results are combined at the end. Hence the difference between minutes and seconds.
This also explains why Redshift is bad at the opposite: retrieving one specific order by its identifier means coordinating every node to return a single row. Aurora does it in 2 ms with an index; Redshift takes hundreds of milliseconds. Redshift does not replace Aurora: it complements it.
Architecture: leader node, compute nodes and slices
graph TD
CLI["Sara's SQL client"] --> L["Leader node<br/>parses, plans, distributes and combines"]
L --> N1["Compute node 1"]
L --> N2["Compute node 2"]
N1 --> S1["Slice 1"]
N1 --> S2["Slice 2"]
N2 --> S3["Slice 3"]
N2 --> S4["Slice 4"]
S1 --> RMS["Managed storage in S3 · RA3"]
S2 --> RMS
S3 --> RMS
S4 --> RMS
The leader node receives the query, parses it, generates the plan, compiles the code and hands it out; it stores no user data and is the only connection point. The compute nodes run their portion and return partial results. The slices are the divisions within each node, one per vCPU: the real unit of parallelism, and the reason the distribution key matters so much, because if the data is not spread evenly across slices, half the hardware sits idle.
RA3 versus DC2, and Redshift Serverless
| DC2 | RA3 | Serverless | |
|---|---|---|---|
| Storage | Local to the node (SSD) | Managed in S3, with a local cache | Managed |
| Scaling compute and data | Coupled | Independent | Automatic |
| Billing unit | Node-hour | Node-hour | RPU-hour |
| When it is switched on | Always | Always (or paused) | Only when there are queries |
| Practical minimum | 2 nodes | 2 nodes | 8 RPUs |
| Administration | High | Medium | None |
DC2 is the older generation: fast, but with local storage, so growing in data forces you to add nodes you do not need for compute. RA3 separates the two, with managed storage on S3 and an automatic local cache. Redshift Serverless removes the concept of a node altogether: you define a workgroup with a base capacity in RPUs (Redshift Processing Units, minimum 8), you pay per RPU-second while queries are running, and when there is no activity there is no compute charge, only storage.
Why Serverless is the option for MercadoFresco
MercadoFresco's analytical usage profile, as measured:
| Figure | Value |
|---|---|
| Reports per day | 3-8, with peaks at month end |
| Target duration per report | 5-30 s |
| Total compute time per day | ≈4 minutes |
| Analytics users | 2 (Sara and an intern) |
| Warehouse volume | 340 GB today, +12 GB/month |
Four minutes of compute a day out of a possible 1,440: a provisioned cluster would be switched on and doing nothing 99.7 % of the time.
Provisioned RA3 (2 × ra3.xlplus) |
Serverless (8 RPU base) | |
|---|---|---|
| Compute | 730 h × 2 × 1.086 = 1,586 USD/month | ~2 h/month × 8 RPU × 0.36 = ≈6 USD |
| Storage | Included up to 32 TB per node | 340 GB × 0.024 = 8 USD |
| Administration | Sizing, pausing, watching | None |
| Total | ≈1,586 USD | ≈14 USD |
Two orders of magnitude, no argument. A provisioned cluster is justified when there are queries practically all day, dozens of analysts and predictable load: that will be MercadoFresco's situation in a few years' time, not today.
# Serverless is made up of a namespace (data, encryption, permissions)
# and a workgroup (capacity, network). They are created separately.
aws redshift-serverless create-namespace \
--namespace-name mercadofresco-analitica \
--admin-username analitica_admin --manage-admin-password \
--kms-key-id alias/mercadofresco-datos \
--default-iam-role-arn arn:aws:iam::111122223333:role/rol-redshift-mercadofresco \
--iam-roles arn:aws:iam::111122223333:role/rol-redshift-mercadofresco \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=analitica Key=Propietario,Value=sara \
Key=CentroCoste,Value=negocio \
--region eu-west-1 --profile mercadofresco-dev
aws redshift-serverless create-workgroup \
--workgroup-name wg-mercadofresco-analitica \
--namespace-name mercadofresco-analitica \
--base-capacity 8 --max-capacity 64 \
--subnet-ids snet-mercadofresco-datos-a snet-mercadofresco-datos-b \
--security-group-ids sg-mercadofresco-basedatos \
--no-publicly-accessible \
--region eu-west-1 --profile mercadofresco-dev--max-capacity 64 is the spending limit: without it, a badly written query scales and bills for it.
And --no-publicly-accessible with private subnets is non-negotiable: the warehouse holds the
purchase history of every customer.
MercadoFresco's star schema
In OLTP you normalise to avoid duplication; in OLAP you denormalise into a star: one large fact table with the numeric measures, surrounded by small dimensions holding the descriptive attributes. Fewer joins, and the ones that remain are against small tables.
graph TD
DP["dim_producto<br/>sk_producto · sku · nombre<br/>categoria · proveedor · alergenos"] --> H
DC["dim_cliente<br/>sk_cliente · segmento<br/>antiguedad · franja_preferida"] --> H
DT["dim_tiempo<br/>sk_tiempo · fecha · dia_semana<br/>es_festivo · semana · mes"] --> H
DU["dim_ciudad<br/>sk_ciudad · ciudad · provincia<br/>comunidad · almacen_asignado"] --> H
H["hechos_pedidos<br/>sk_tiempo · sk_producto · sk_cliente · sk_ciudad<br/>unidades · importe · descuento · coste_reparto"]
-- Fact table: one row per order line. This is the big one.
CREATE TABLE hechos_pedidos (
sk_tiempo INTEGER NOT NULL,
sk_producto INTEGER NOT NULL,
sk_cliente INTEGER NOT NULL,
sk_ciudad SMALLINT NOT NULL,
id_pedido BIGINT NOT NULL, -- reference to the source system
unidades SMALLINT NOT NULL,
importe DECIMAL(10,2) NOT NULL,
descuento DECIMAL(10,2) NOT NULL DEFAULT 0,
coste_reparto DECIMAL(10,2) NOT NULL DEFAULT 0
)
DISTSTYLE KEY
DISTKEY (sk_cliente) -- explained in the next section
SORTKEY (sk_tiempo, sk_ciudad);
-- Small dimension: replicated whole on every node with DISTSTYLE ALL.
CREATE TABLE dim_ciudad (
sk_ciudad SMALLINT NOT NULL,
ciudad VARCHAR(80) NOT NULL,
provincia VARCHAR(80) NOT NULL,
comunidad VARCHAR(80) NOT NULL,
almacen_asignado VARCHAR(40) NOT NULL
)
DISTSTYLE ALL
SORTKEY (sk_ciudad);Two details that come as a surprise if you arrive from PostgreSQL. Redshift does not enforce primary
or foreign keys: you can declare them, and it is worth doing because the planner uses them to
optimise, but it does not verify them —integrity is guaranteed by the load process—; and there are
no indexes, because the sort key plays that role. Dimensions should also have surrogate keys
(sk_*, small integers) rather than the source identifiers: they take up less space, compress better
and make it possible to keep history when an attribute changes —if a customer moves city, the old
orders must go on counting towards the city they were delivered to.
Distribution keys
The distribution key decides which slice each row lands on, and it is the decision that affects performance most.
| Style | How it spreads | When to use it | Risk |
|---|---|---|---|
KEY |
By hash of a column | A large table always joined on that column | Skew if the column is badly spread |
ALL |
Full copy on every node | Small dimensions (< 2-3 M rows) | Multiplies storage and writes |
EVEN |
Round-robin, no criterion | Tables that are not joined, or with no clear column | Redistribution on every join |
AUTO |
Redshift decides and changes it | Default starting point | Less control |
What happens when you choose badly is concrete and measurable. If hechos_pedidos is distributed by
sk_producto and the query joins on sk_cliente, Redshift has to redistribute the fact table
across the network on every query: that is the DS_DIST_BOTH step in the execution plan, and it can
multiply the duration by ten. Worse still is skew: if you distribute by sk_ciudad and 45 % of
the orders come from Madrid, that 45 % lands on a single slice and one process works while fifteen
wait. It is the hot key from 06-02 under another name.
In MercadoFresco DISTKEY (sk_cliente) is chosen because there are tens of thousands of customers
spread reasonably evenly and because the cohort and average basket queries group by customer. And the
four dimensions go with DISTSTYLE ALL because they are tiny: replicating them costs little and
eliminates all redistribution in the joins. To diagnose skew you compare the number of rows per slice
in the system views: if the maximum and the minimum differ a lot, the key is badly chosen.
Sort keys
The sort key determines the physical order of the rows on disk. Redshift stores the minimum and maximum value of every one-megabyte block, so if a query filters on the sort key the engine discards whole blocks without reading them. It is the conceptual equivalent of a clustered index.
SORTKEY (sk_tiempo, sk_ciudad) on hechos_pedidos reflects the fact that every one of Sara's
queries filters on a date range: with 18 months of history and a query about the last quarter,
Redshift discards 83 % of the blocks before reading anything. The order matters: the first column
must be the one most used for range filtering, and putting sk_ciudad first would waste almost all
the benefit, because the city filter is an equality one and appears less often.
Three warnings. New loads arrive unsorted and sit in an unsorted region that VACUUM SORT ONLY
reorganises; with a date key and incremental loading by date it is hardly ever needed, because the
data arrives almost in order already. ANALYZE refreshes the planner's statistics, without which
the plan can be dreadful —Serverless runs both by itself, in the background. And interleaved sort
keys (INTERLEAVED) give equal weight to several columns, but they only pay off in specific cases and
cost a lot in maintenance: the default recommendation is the compound one.
Loading data with COPY
COPY is the only sensible way to load volume into Redshift: it reads from S3 in parallel from every
slice at once. A row-by-row INSERT is between a hundred and a thousand times slower.
COPY hechos_pedidos
FROM 's3://mercadofresco-informes-analitica/hechos/pedidos/2026/08/02/'
IAM_ROLE 'arn:aws:iam::111122223333:role/rol-redshift-mercadofresco'
FORMAT AS PARQUET;Four decisions lie behind those four lines. Parquet, not CSV: it is columnar and compressed at
source, so the load is faster, there is no schema to declare and the types come defined —there is no
ambiguity between 12,40 and 12.40. A prefix, not a file: COPY loads every object under the
prefix in parallel, and the rule of thumb is to generate a multiple of the number of slices in
files of 1 to 128 MB compressed, because a single giant file leaves every slice but one idle.
IAM_ROLE, never access keys in the SQL: the role is assumed by the cluster and audited by
trail-mercadofresco. And a manifest when exactness is required —a JSON that explicitly lists the
objects with "mandatory": true—, which is what guarantees that the nightly load processes exactly
the files the export generated, not one more and not one less.
{"entries": [
{"url": "s3://mercadofresco-informes-analitica/hechos/pedidos/2026/08/02/part-000.parquet",
"mandatory": true},
{"url": "s3://mercadofresco-informes-analitica/hechos/pedidos/2026/08/02/part-001.parquet",
"mandatory": true}
]}After every load it is worth reviewing STL_LOAD_ERRORS, which explains row by row what failed. It is
the first table to look at when a COPY complains.
The nightly pipeline
graph LR
A["Aurora · aurora-mf-lector-lotes<br/>02:00"] --> B["Incremental export<br/>the day's orders"]
B --> C["S3 · mercadofresco-informes-analitica<br/>Parquet partitioned by date"]
C --> D["COPY into staging tables"]
D --> E["Transform and load<br/>dimensions and facts"]
E --> F["Refresh materialised views"]
F --> G["Success metric to CloudWatch<br/>and notice to alertas-mercadofresco on failure"]
Four rules make it reliable. Incremental, not full: only orders with a fecha_modificacion later
than the last watermark are exported, not all 48 million every night. Idempotent: if the process
is retried the result must be the same, and that is achieved by deleting the day's partition before
loading it, inside a transaction. From the replica, never from the writer: that is exactly what
aurora-mf-lector-lotes exists for. And with an alarm, because a silent failure means Sara looks
at stale data without knowing it, which is worse than having no report at all.
Orchestrating these steps —with retries, dependencies and failure handling— is the job of Step Functions and EventBridge, covered in 07-03 and 07-04. Here it is enough to know that the pipeline exists and what guarantees it has to meet.
Zero-ETL integration from Aurora
Zero-ETL integration continuously replicates Aurora PostgreSQL tables to Redshift, with a lag of seconds and without writing a single line of pipeline. You configure an integration between the source cluster and the destination namespace, and AWS maintains the copy.
| Your own pipeline | Zero-ETL integration | |
|---|---|---|
| Lag | Hours (nightly) | Seconds |
| Transformations | Any | None: the tables arrive as they are |
| Resulting model | Star schema, optimised | A replica of the OLTP schema |
| Maintenance | The team | AWS |
| Cost | Compute for the process | Replication I/O |
They are not mutually exclusive, and the combination is what MercadoFresco ends up using: zero-ETL integration brings the normalised tables over in near real time and a process inside Redshift transforms them into the star schema. You gain freshness and remove the most fragile part of the pipeline while keeping the optimised model.
Redshift Spectrum
Spectrum lets you query data that is sitting in S3 without loading it, through external tables defined in the AWS Glue data catalogue.
CREATE EXTERNAL SCHEMA historico_s3
FROM DATA CATALOG DATABASE 'mercadofresco_analitica'
IAM_ROLE 'arn:aws:iam::111122223333:role/rol-redshift-mercadofresco';
-- Joins hot data (in Redshift) with cold data (in S3) in a single query.
SELECT h.sk_ciudad, SUM(h.importe) AS current_total, SUM(a.importe) AS historical_total
FROM hechos_pedidos h
LEFT JOIN historico_s3.pedidos_archivo a ON a.sk_ciudad = h.sk_ciudad
GROUP BY h.sk_ciudad;MercadoFresco's case: keep the last 24 months in Redshift and leave in S3, in infrequent access classes (02-03), everything older plus the abandoned baskets we exported in 06-02. Billing is by TB scanned, so partitioning by date and using Parquet is what separates a query costing pennies from one costing tens of dollars.
Redshift versus Athena, and AWS Glue as catalogue
Athena (05-03, where we queried trail-mercadofresco) also runs SQL over S3. The honest question is
when it is enough.
| Amazon Athena | Amazon Redshift | |
|---|---|---|
| Cost model | 5 USD per TB scanned | RPU-hour or node-hour |
| Infrastructure | None | Namespace or cluster |
| Typical latency | 5-60 s | 1-10 s (data loaded) |
| Optimisation | Format and partitions only | Distribution, sorting, materialised views |
| Concurrency | Good, with quotas | Very good, with WLM |
| Complex joins | Acceptable | Very good |
| Materialised views | No | Yes |
| When | Sporadic queries over data in S3 | Repeated queries, dashboards, dimensional model |
Athena is enough for occasional exploration, log analysis and any case of a few queries a month. Redshift is needed when the same queries repeat daily, when there are dashboards that refresh by themselves, when there are joins between several large tables, or when repeated scanning in Athena starts costing more than the warehouse. For MercadoFresco, with 3-8 daily reports over the same tables and a dimensional model to maintain, Redshift Serverless is the choice; Athena remains the tool for the CloudTrail logs and for one-off exploration over S3.
AWS Glue provides the data catalogue: the central register of which tables exist, where they are and what schema they have. Athena, Spectrum and Glue jobs all share it, so defining a table once makes it visible from all three, and the crawlers can deduce the schema by walking through a prefix in S3.
Sara's business queries
-- 1. Average basket and revenue by city, last 18 months.
-- The filter on sk_tiempo exploits the sort key and discards blocks.
SELECT ci.ciudad,
COUNT(DISTINCT h.id_pedido) AS orders,
ROUND(SUM(h.importe) / COUNT(DISTINCT h.id_pedido), 2) AS average_basket,
ROUND(SUM(h.importe), 2) AS revenue
FROM hechos_pedidos h
JOIN dim_ciudad ci ON ci.sk_ciudad = h.sk_ciudad
JOIN dim_tiempo t ON t.sk_tiempo = h.sk_tiempo
WHERE t.fecha >= DATEADD(month, -18, CURRENT_DATE)
GROUP BY ci.ciudad
ORDER BY revenue DESC;-- 2. Products that run out most on Fridays during the peak slot.
-- Compares units sold on Fridays 17-21h with the average of the other days.
WITH fridays AS (
SELECT h.sk_producto, SUM(h.unidades) AS friday_units
FROM hechos_pedidos h
JOIN dim_tiempo t ON t.sk_tiempo = h.sk_tiempo
WHERE t.dia_semana = 5
AND t.fecha >= DATEADD(month, -6, CURRENT_DATE)
GROUP BY h.sk_producto
),
rest AS (
SELECT h.sk_producto, SUM(h.unidades) / 6.0 AS avg_daily_units
FROM hechos_pedidos h
JOIN dim_tiempo t ON t.sk_tiempo = h.sk_tiempo
WHERE t.dia_semana <> 5
AND t.fecha >= DATEADD(month, -6, CURRENT_DATE)
GROUP BY h.sk_producto
)
SELECT p.nombre, p.categoria,
v.friday_units,
ROUND(v.friday_units / NULLIF(r.avg_daily_units, 0), 2) AS friday_factor
FROM fridays v
JOIN rest r ON r.sk_producto = v.sk_producto
JOIN dim_producto p ON p.sk_producto = v.sk_producto
WHERE v.friday_units > 200
ORDER BY friday_factor DESC
LIMIT 25;This is the query that lets Marta decide how much fresh produce to order from the market on Thursday night: not the best seller overall, but the one that spikes specifically on Friday.
-- 3. Cohorts: what percentage of customers are still buying N months after their
-- first order, grouped by the month in which they signed up.
WITH first_order AS (
SELECT h.sk_cliente, DATE_TRUNC('month', MIN(t.fecha)) AS signup_month
FROM hechos_pedidos h JOIN dim_tiempo t ON t.sk_tiempo = h.sk_tiempo
GROUP BY h.sk_cliente
),
activity AS (
SELECT f.signup_month,
DATEDIFF(month, f.signup_month, DATE_TRUNC('month', t.fecha)) AS relative_month,
COUNT(DISTINCT h.sk_cliente) AS active_customers
FROM hechos_pedidos h
JOIN dim_tiempo t ON t.sk_tiempo = h.sk_tiempo
JOIN first_order f ON f.sk_cliente = h.sk_cliente
GROUP BY 1, 2
)
SELECT signup_month, relative_month, active_customers,
ROUND(100.0 * active_customers /
FIRST_VALUE(active_customers)
OVER (PARTITION BY signup_month ORDER BY relative_month), 1) AS retention_pct
FROM activity
WHERE relative_month BETWEEN 0 AND 12
ORDER BY signup_month, relative_month;All three took minutes on Aurora and take seconds on Redshift. And all three are impossible in DynamoDB: this is the "exploratory queries" criterion from 06-01 turned into SQL.
Materialised views
A materialised view stores the pre-computed result of a query. Redshift can refresh it incrementally, processing only the new data, and it automatically rewrites queries so that they use the view even when the user does not mention it.
CREATE MATERIALIZED VIEW mv_ventas_diarias_ciudad
AUTO REFRESH YES
AS
SELECT t.fecha, h.sk_ciudad,
COUNT(DISTINCT h.id_pedido) AS orders,
SUM(h.importe) AS revenue,
SUM(h.unidades) AS units
FROM hechos_pedidos h
JOIN dim_tiempo t ON t.sk_tiempo = h.sk_tiempo
GROUP BY t.fecha, h.sk_ciudad;The business dashboard goes from aggregating 48 million rows to reading a few thousand. AUTO REFRESH YES lets Redshift decide when to refresh according to activity; with a nightly pipeline an explicit
REFRESH MATERIALIZED VIEW at the end of the load works just as well, and gives exact control over
when the numbers Sara sees change.
Concurrency, WLM queues and concurrency scaling
Workload management (WLM) organises queries into queues with their own memory and priority, so that one heavy report does not block the rest. Manual WLM requires configuring queues, memory and concurrency by hand and does not adapt; automatic WLM only asks for priorities and adjusts itself, and it is the default choice except in very specific cases.
With automatic WLM you define priorities per user group: dashboards on HIGHEST because they are
fast and many eyes are on them, Sara's exploratory queries on NORMAL, and the nightly load on LOW
because nobody minds if it takes ten minutes longer in the middle of the night.
Two complementary mechanisms: concurrency scaling, which adds temporary capacity when a queue
builds up —in Serverless it is built into RPU scaling—, and query monitoring rules (QMR), which
automatically abort anything that runs away (for example, any query that exceeds 15 minutes or scans
more than 500 GB). It is the most effective way of stopping an accidental SELECT * from eating the
budget.
Security, permissions and anonymisation
Network. The workgroup lives in the private subnets snet-mercadofresco-datos-a and -b, with
sg-mercadofresco-basedatos and no public access; Sara connects through the console's query editor
v2, which requires nothing to be opened. Encryption at rest with alias/mercadofresco-datos and
in transit with mandatory TLS. And read-only permissions for the mercadofresco-analitica group:
CREATE GROUP mercadofresco_analitica;
CREATE USER sara PASSWORD DISABLE IN GROUP mercadofresco_analitica; -- federated
GRANT USAGE ON SCHEMA analitica TO GROUP mercadofresco_analitica;
GRANT SELECT ON ALL TABLES IN SCHEMA analitica TO GROUP mercadofresco_analitica;
ALTER DEFAULT PRIVILEGES IN SCHEMA analitica
GRANT SELECT ON TABLES TO GROUP mercadofresco_analitica;
-- A view that exposes what is needed without any identifying data.
CREATE VIEW analitica.v_clientes_segmento AS
SELECT sk_cliente, segmento, antiguedad_meses, franja_preferida, sk_ciudad
FROM analitica.dim_cliente;
REVOKE SELECT ON analitica.dim_cliente FROM GROUP mercadofresco_analitica;
GRANT SELECT ON analitica.v_clientes_segmento TO GROUP mercadofresco_analitica;ALTER DEFAULT PRIVILEGES is the line people forget: without it, the tables created tomorrow will not
be readable and somebody will end up granting excessive permissions to get past the problem.
Anonymisation and GDPR. The warehouse must not contain name, postal address, telephone number, email or identity document. No business question needs them: the average basket by city needs the city, not the street. The pipeline replaces the customer identifier with a surrogate key that has no reversible mapping outside a controlled store, aggregates the address to city or postcode level, and discards the rest. Beyond that, data analysis is a different purpose from managing the order and needs its own legal basis, its mention in the privacy policy and a documented retention policy. Redshift offers row-level access control and dynamic column masking for cases where some sensitive item has to remain. All of this design must be reviewed by the data protection officer before the first load: it is far cheaper than redesigning the warehouse afterwards.
Costs, pause and resume
| Item | Indicative eu-west-1 price |
MercadoFresco |
|---|---|---|
| Serverless, RPU-hour | ~0.36 USD | ~2 h/month of activity = 6 USD |
| Managed storage | 0.024 USD/GB-month | 340 GB = 8 USD |
| Spectrum | 5 USD/TB scanned | Occasional, < 2 USD |
| Snapshots beyond the size | S3 price | Negligible |
| Total | ≈16 USD/month |
With Serverless, the "pause" is automatic: with no queries there are no RPUs and no compute charge. Even so it is worth setting a usage limit that warns or cuts off above a monthly threshold:
aws redshift-serverless create-usage-limit \
--resource-arn arn:aws:redshift-serverless:eu-west-1:111122223333:workgroup/wg-mercadofresco-analitica \
--usage-type serverless-compute --amount 200 --period monthly \
--breach-action deactivate \
--region eu-west-1 --profile mercadofresco-devOn provisioned clusters, pause-cluster and resume-cluster stop the compute charge while keeping
the data, and they can be scheduled: it is the lever that turns a 1,500 USD/month development
environment into a 300 USD one.
Cleanup. When you finish any test: delete the workgroup and the namespace —with a final snapshot if the data matters—, drop the external Glue tables and review whatever objects you have left in
mercadofresco-informes-analitica. Check in Cost Explorer that theComponente=analiticatag goes back to zero the following month.
Common Mistakes and Tips
Using Redshift as a transactional database. This is the serious conceptual mistake. Redshift has
no indexes for point lookups, and row-by-row INSERT statements are painfully slow. Load in batches
with COPY and query in aggregate; transactional work stays in Aurora.
Loading with INSERT. Between a hundred and a thousand times slower than COPY, and it also
fragments the table. If the data arrives one row at a time, accumulate it in S3 and load in batches.
Choosing the distribution key by intuition. Distributing by a column with few distinct values
produces skew and wastes the parallelism. Distributing by a column other than the one used in the
joins produces redistribution on every query. When in doubt, AUTO, and check afterwards with
EXPLAIN looking for DS_DIST_BOTH.
Putting a column you never filter on into the sort key. It contributes nothing. The first column must be the one you filter on by range, which is almost always the date.
Forgetting ANALYZE and VACUUM on provisioned clusters. Without statistics the planner makes
bad decisions and without VACUUM the table degrades. Serverless runs them itself, but know they exist.
Loading personal data without anonymising it "so as not to lose detail". You end up with the company's most sensitive asset replicated in a system more people have access to, with no clear legal basis. Anonymisation is designed before the first load.
A single enormous file in the COPY. It leaves every slice but one idle. Split into a multiple
of the number of slices, in chunks of 1 to 128 MB compressed.
Leaving a provisioned cluster switched on "to try things out". It is the most expensive slip in this module: 1,500 USD a month for a cluster nobody uses. Serverless, or a scheduled pause.
Tip: measure the before and the after. Record how long Sara's three reports take on Aurora and
compare with Redshift. It is the number that justifies the project to management, and you get it free
in SVL_QUERY_METRICS and in the module 5 dashboards.
Tip: start small and with Spectrum. Before designing the complete star schema, export to Parquet and query with Spectrum or Athena. If that is enough, you have saved yourself a warehouse. Complexity is added once it has been shown to be necessary.
Exercises
Exercise 1: choosing distribution and sort keys
MercadoFresco adds the hechos_reparto fact table, with one row per delivery: 12 million rows,
columns sk_tiempo, sk_ciudad, sk_repartidor (40 distinct values), sk_pedido,
minutos_entrega, km_recorridos and incidencia (boolean, true in 3 % of cases). The usual queries
are: average delay by city and month; incidents per driver over the last week; and a cross-reference
with hechos_pedidos on sk_pedido to relate delay to order value.
Decide the DISTSTYLE, DISTKEY and SORTKEY, justify each choice, and state what would happen if
it were distributed by sk_repartidor and which signal in the execution plan would give it away.
Exercise 2: Athena or Redshift
A sister company of MercadoFresco, selling catering equipment, has 40 GB of order history in S3 in CSV format. Its analyst runs between three and five queries a month, always exploratory and always different from one another, and there are no dashboards. It is considering setting up Redshift Serverless "because that is what the big players do".
Answer: (a) what do you recommend and why, with numbers; (b) which two changes to the data would make its option much cheaper and faster without changing service; (c) which three concrete signals would indicate, a year from now, that the time has come to move to Redshift.
Exercise 3: designing the pipeline with guarantees
Write the design of the nightly pipeline that carries the day's orders from
aurora-mercadofresco-pedidos to hechos_pedidos. It must cover: which endpoint it reads from and
why; how only new or modified records are selected; format, partitioning and file size in S3; how
idempotence is guaranteed if the process is retried; how dimensions are loaded when an attribute
changes (for example, a customer who moves city); what is done with personal data; and how a failure
is detected and notified. Also state which part of this design would disappear if zero-ETL integration
were used.
Solutions
Solution 1
DISTSTYLE KEY with DISTKEY (sk_pedido) and SORTKEY (sk_tiempo, sk_ciudad).
Distribution: the most expensive of the three queries is the cross-reference with hechos_pedidos,
which is the large table. If both tables are distributed by the same column —sk_pedido— the rows
that have to be joined are on the same slice and the join resolves locally, without moving
anything across the network. That is the co-location pattern, and it is the main reason for choosing
KEY. sk_pedido also has high cardinality and an even spread, so there is no skew. Note:
hechos_pedidos is distributed by sk_cliente, so to exploit co-location fully you would have to
consider unifying the criterion or accepting redistribution of the smaller table, which with 12
million rows is bearable; the decision is taken by measuring with EXPLAIN.
Sorting: the first two queries filter on a time range ("by month", "the last week"), so sk_tiempo
has to come first so that blocks can be discarded. sk_ciudad in second position helps the first
report.
If it were distributed by sk_repartidor: with 40 distinct values and a 16-slice cluster, at most
16 slices would receive data and very unevenly at that —drivers in Madrid have far more deliveries.
The result is severe skew: a few slices with millions of rows and the rest almost empty, with the
parallelism wasted. The signal in the execution plan would be DS_DIST_BOTH or DS_BCAST_INNER in
the join with hechos_pedidos, and in the system views you would see an enormous difference between
the slice with the most rows and the one with the fewest. The general rule: never distribute by a
low-cardinality column.
Solution 2
(a) Athena, without a doubt. With 40 GB and five queries a month, even if each one scanned the whole history that would be 0.04 TB × 5 × 5 USD = 1 USD a month. Redshift Serverless would cost storage plus the compute of each session, plus designing and maintaining a dimensional model nobody is going to exploit. And the deciding characteristic: the queries are always different and exploratory, so there is nothing to pre-compute and no materialised view to amortise. All of Redshift's value —optimised model, views, concurrency— rests on repetition, and here there is none.
(b) Two changes: convert to Parquet and partition by date. Parquet with compression reduces 40 GB to some 6-8 GB and, being columnar, a query that uses 4 columns out of 30 scans a fraction of that. Partitioning by year and month in the S3 prefix lets Athena discard whole partitions when the query filters by date. Combined, it is common to go from tens of gigabytes scanned to hundreds of megabytes: the bill drops to pennies and the query from a minute to a few seconds. Both changes are an afternoon's Glue work and do not change service.
(c) Three signals for moving to Redshift. First, repetition: dashboards or recurring daily reports appear, and at that point materialised views and the dimensional model start to pay for themselves. Second, concurrency: they go from one analyst to a team with simultaneous queries and begin to hit Athena's quotas. Third, crossed cost: the monthly Athena bill approaches the cost of a Serverless workgroup —15-20 USD a month with this profile—, the point at which Redshift gives more for the same money. A fourth, qualitative sign: when queries start joining three or more large tables, Athena degrades a long way before Redshift does.
Solution 3
Source and endpoint. It reads from the custom endpoint that points to
aurora-mf-lector-lotes, the isolated replica from 06-03. Never from the writer —the export competes
with the orders— and never from the general reader endpoint, because it would fill the cache of the
replica that serves customers their history with pages 18 months old.
Incremental selection. A watermark in Parameter Store
(/mercadofresco/produccion/analitica/marca-agua) with the last fecha_modificacion processed. The
query selects WHERE fecha_modificacion > :marca AND fecha_modificacion <= :corte, with :corte fixed
at the start of the process so that writes occurring during the export are left for the following
night and are neither lost nor duplicated. It requires fecha_modificacion to be indexed and
maintained by a trigger.
Format in S3. Parquet with Snappy compression, in
s3://mercadofresco-informes-analitica/hechos/pedidos/anio=2026/mes=08/dia=02/, with files of 64-128
MB in a number that is a multiple of the parallelism. Partitioning by date in the prefix is what makes
Spectrum queries cheap and allows a specific day to be reprocessed.
Idempotence. Inside a transaction: DELETE FROM hechos_pedidos WHERE sk_tiempo = :dia; followed
by the COPY of the partition, with a manifest that explicitly enumerates the files and
"mandatory": true. If the process is retried, the result is identical. The manifest also prevents
partial files from an aborted run being loaded.
Changing dimensions. A type 2 slowly changing dimension is applied: when a customer moves
city, their row is not updated; the current one is closed (fecha_fin, es_actual = false) and a new
one is inserted with a different surrogate key. That way old orders keep pointing at the row that was
current when they were delivered and the history by city does not rewrite itself. If it were
overwritten (type 1), last year's revenue for Valencia would change because a customer moved to
Bilbao, which is exactly the kind of error that destroys trust in a data warehouse.
Personal data. Name, address, telephone and email do not leave Aurora. The export projects
only the necessary columns, replaces id_cliente with the surrogate key sk_cliente and aggregates
the address to city and postcode. The mapping between sk_cliente and id_cliente lives in Aurora,
with restricted access, not in the warehouse.
Failure detection. Every run publishes a custom metric
MercadoFresco/Analitica/CargaCorrecta (1 or 0) and FilasCargadas in the MercadoFresco/Tienda
namespace. Two alarms towards alertas-mercadofresco: one if there has been no successful load before
06:00 —the dangerous case is silence, not error— and another if FilasCargadas deviates more than
50 % from the average of the last 7 days, which detects a truncated export. Load errors are looked up
in STL_LOAD_ERRORS.
What disappears with zero-ETL integration. The whole extraction and landing: incremental query,
watermark, Parquet, partitioning, manifest and COPY; AWS keeps the tables replicated within seconds.
What does not disappear is the transformation into the star schema, the type 2 dimension logic, the
anonymisation —critical: replication brings the tables as they are, personal data included, so the
projection and the masking have to be done inside Redshift with views and permissions— and watching
freshness. The fragile half is simplified, not all of it.
Conclusion
Sara's reports have left the transactional database. You understand why they had to leave: a query that aggregates 18 months reads 18.2 GB on a row-oriented engine in order to use 1.34 GB of useful data, uncompressed and with a single process, whereas columnar storage reads only the columns it needs, compresses them 3 to 4 times because neighbouring values look alike, and processes them in parallel across every slice at once. From 4-6 minutes to 2-6 seconds, and with the symmetry worth remembering: Redshift is equally bad at returning one specific order by its identifier. It does not replace Aurora, it complements it.
You know the architecture —leader node that plans and combines, compute nodes, slices as the real
unit of parallelism—, the difference between DC2, RA3 and Serverless, and the arithmetic that
decides it for MercadoFresco: four minutes of compute a day means a provisioned cluster would be
switched on 99.7 % of the time doing nothing, 1,586 USD against 14 USD a month. With
mercadofresco-analitica as the namespace, wg-mercadofresco-analitica as the workgroup in private
subnets, and --max-capacity as a real spending limit.
You can design the analytical schema: the star schema with hechos_pedidos and the dimensions
dim_producto, dim_cliente, dim_tiempo and dim_ciudad, with surrogate keys and without trusting
foreign keys that Redshift declares but does not verify. And above all you can choose the two keys
that decide performance: the distribution key, with KEY to co-locate the large tables that are
joined, ALL for small dimensions and the warning that a low-cardinality column produces skew and
leaves half the hardware idle; and the sort key, with the date first, which discards whole blocks
thanks to the per-block minimums and maximums.
You can handle loading with COPY from mercadofresco-informes-analitica —Parquet, one prefix
with many files of 1 to 128 MB, IAM_ROLE instead of keys, a manifest with mandatory when exactness
matters— and the nightly pipeline: incremental, idempotent, from the replica and with an alarm,
because the dangerous failure is the silent one. You know zero-ETL integration from Aurora and
when to combine it with your own transformation; Spectrum for querying S3 without loading; and the
honest comparison with Athena, which is enough when queries are sporadic and different, with Glue
as the shared catalogue. Plus materialised views, automatic WLM with priorities per group and
the rules that abort the runaway query.
And you know that this is the company's most sensitive asset: private subnets, encryption with
alias/mercadofresco-datos, read-only access for the mercadofresco-analitica group with ALTER DEFAULT PRIVILEGES so that tomorrow's tables are covered too, and anonymisation designed before the
first load —no business question needs the customer's street— with review by the data protection
officer. For around 16 USD a month, with a usage limit configured and the instruction to clean up
whatever you create to practise.
One workload remains of the four we diagnosed in 06-01, and it is the most absurd of them all. The catalogue is queried 4,100 times a minute at peak hour in order to return, 94 % of the time, exactly what it returned the time before: prices and descriptions that change once a day, at 06:00, when the delivery from the market arrives. Aurora resolves each of those queries correctly in a few milliseconds, but doing it 4,100 times a minute in order to contribute no new information at all is wasted work paid for in latency, in capacity and on the bill. In 06-05, "Amazon ElastiCache", the catalogue will be served from memory in microseconds: we will look at Redis versus Memcached, the caching patterns with their code, the data structures applied to the Friday ranking and the fast basket, the sessions that will finally make the shop truly stateless, and the close of the module with MercadoFresco's complete data layer.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
