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

  1. OLAP versus OLTP: why a report kills a transactional database
  2. Columnar storage, compression and massively parallel execution
  3. Architecture: leader node, compute nodes and slices
  4. RA3, DC2 and Redshift Serverless
  5. MercadoFresco's star schema
  6. Distribution and sort keys
  7. Loading data with COPY and the nightly pipeline
  8. Zero-ETL integration from Aurora
  9. Redshift Spectrum, Athena and AWS Glue
  10. Sara's business queries
  11. Materialised views
  12. Concurrency, WLM queues and concurrency scaling
  13. Security, permissions and anonymisation
  14. Costs, pause and resume
  15. Common mistakes and tips
  16. Exercises
  17. 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-dev

On 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 the Componente=analitica tag 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.

© Copyright 2026. All rights reserved