<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maithreyan</title>
    <description>The latest articles on DEV Community by Maithreyan (@maithreyan11).</description>
    <link>https://dev.to/maithreyan11</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F527949%2F527ae63c-412d-4463-8c47-e15a7547d511.jpeg</url>
      <title>DEV Community: Maithreyan</title>
      <link>https://dev.to/maithreyan11</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maithreyan11"/>
    <language>en</language>
    <item>
      <title>Why Apache Airflow Instead of Cron? A Deep Dive Into How Airflow Actually Schedules Your DAGs</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Wed, 12 Aug 2026 00:07:25 +0000</pubDate>
      <link>https://dev.to/maithreyan11/why-apache-airflow-instead-of-cron-a-deep-dive-into-how-airflow-actually-schedules-your-dags-38np</link>
      <guid>https://dev.to/maithreyan11/why-apache-airflow-instead-of-cron-a-deep-dive-into-how-airflow-actually-schedules-your-dags-38np</guid>
      <description>&lt;p&gt;"Why not just use a cron job?" is the first question I get whenever someone sees an Airflow DAG. Fair question. Cron works. It's been around for decades. It's simple.&lt;/p&gt;

&lt;p&gt;The real answer isn't that cron is bad — it's that cron solves a different problem than Airflow does.&lt;/p&gt;

&lt;p&gt;Cron is a &lt;strong&gt;job scheduler&lt;/strong&gt;. It runs a command at a fixed time. That's it. It doesn't know whether the command succeeded, whether its dependencies are satisfied, or whether it should even run at all today. It just fires the command and moves on.&lt;/p&gt;

&lt;p&gt;Airflow is a &lt;strong&gt;workflow orchestrator&lt;/strong&gt;. It doesn't just schedule tasks — it models them as a graph of dependencies, tracks their state, retries failed ones, and gives you a UI to see what ran, what failed, and why.&lt;/p&gt;

&lt;p&gt;Here's where that difference actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem cron can't solve
&lt;/h2&gt;

&lt;p&gt;Imagine a simple ETL pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract raw data from an API&lt;/li&gt;
&lt;li&gt;Validate and clean it&lt;/li&gt;
&lt;li&gt;Load into a warehouse&lt;/li&gt;
&lt;li&gt;Run a transformation&lt;/li&gt;
&lt;li&gt;Send a Slack alert if anything fails&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With cron, you'd write five separate cron entries, one per step, and hope the timing works out. If step 2 fails but step 3 runs anyway, you now have bad data in your warehouse. If step 4 takes twice as long one day, you've silently broken your SLA. Nobody gets notified unless you manually add alerting logic to every script.&lt;/p&gt;

&lt;p&gt;With Airflow, you model this as a DAG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DAG&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow.operators.python&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PythonOperator&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;DAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dag_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily_etl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 6 * * *&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;catchup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

    &lt;span class="n"&gt;extract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;extract_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;validate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;validate_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;load_to_warehouse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PythonOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;python_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_transformation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;extract&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Airflow guarantees the order. If &lt;code&gt;validate&lt;/code&gt; fails, &lt;code&gt;load&lt;/code&gt; and &lt;code&gt;transform&lt;/code&gt; never run. You get automatic retries, failure alerts, and a web UI that shows exactly where the pipeline broke and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Airflow actually schedules a DAG
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. Airflow doesn't just "run your Python script at a fixed time" the way cron does.&lt;/p&gt;

&lt;p&gt;When you define a DAG with a &lt;code&gt;schedule&lt;/code&gt; (or &lt;code&gt;schedule_interval&lt;/code&gt; in older versions), Airflow doesn't pass that cron expression directly to the OS scheduler. Instead, it converts it into a &lt;strong&gt;timetable&lt;/strong&gt; — an internal object that determines when a DAG run should be created.&lt;/p&gt;

&lt;p&gt;The scheduler process runs continuously, checking every few seconds whether any DAGs are ready to run based on their timetable. When a DAG is due, the scheduler creates a &lt;strong&gt;DagRun&lt;/strong&gt; object for that execution date and queues up the tasks. The actual execution happens on worker processes (via the executor you've configured — Local, Celery, or Kubernetes), not directly from the scheduler itself.&lt;/p&gt;

&lt;p&gt;This matters for two reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Airflow schedules based on data intervals, not wall-clock time.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A DAG with &lt;code&gt;schedule="0 6 * * *"&lt;/code&gt; (daily at 6 AM) doesn't run at 6 AM to process data &lt;em&gt;at that moment&lt;/em&gt;. It runs at 6 AM to process data for the &lt;em&gt;previous&lt;/em&gt; interval — typically yesterday, if you're on a daily schedule. Airflow's &lt;code&gt;execution_date&lt;/code&gt; is the start of the data interval, not the time the task actually runs.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;catchup=True&lt;/code&gt; (the default in older Airflow versions) can surprise you: if you deploy a new daily DAG on January 10th with a start date of January 1st, Airflow will immediately create DagRuns for every day from Jan 1–9 and try to backfill them all, because it thinks you're behind on processing those intervals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The scheduler is stateful and centralized.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Unlike cron, which runs independently on each machine, Airflow's scheduler is a single process (or a small cluster in HA setups) that maintains a global view of all DAGs, their schedules, and their current state. It knows which tasks are running, which are queued, which have failed, and which are blocked by upstream dependencies. This is what enables features like automatic retries, SLA monitoring, and the ability to pause or unpause a DAG from the UI without touching the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters in production
&lt;/h2&gt;

&lt;p&gt;The real difference isn't features — it's what happens when things go wrong.&lt;/p&gt;

&lt;p&gt;Cron jobs fail silently. Logs are scattered across servers. Backfilling a missed run means manually re-running scripts in the right order. Scaling to 50+ pipelines means managing hundreds of crontab entries across multiple machines, with no central visibility into what's running or what's broken.&lt;/p&gt;

&lt;p&gt;Airflow tracks everything — task state, execution history, retry counts, SLAs. You can backfill a date range with one command. You can see which tasks are blocking others. You can add sensors that wait for external data to arrive before starting a downstream task. None of this exists in cron; you'd have to build it yourself, and you'd build it worse than Airflow already has.&lt;/p&gt;

&lt;p&gt;I've seen this play out directly: a team running 30+ cron-based ETL scripts had no idea when a critical pipeline silently failed for three days because the script exited with a 0 status code even though the data was stale. Moving to Airflow meant that same pipeline would have automatically retried, alerted on failure, and shown up in red on a dashboard — impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  When cron is actually the right choice
&lt;/h2&gt;

&lt;p&gt;I'm not saying "never use cron." Cron is perfect for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Simple, independent tasks (daily backups, log rotation, health checks)&lt;/li&gt;
&lt;li&gt;Small-scale automation (fewer than ~5 scripts, no dependencies between them)&lt;/li&gt;
&lt;li&gt;Situations where silent failure is acceptable or you have other monitoring in place&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your pipeline is "run this one script at 2 AM and that's it," cron is fine. If your pipeline has dependencies, retries, alerts, or cross-team visibility needs, Airflow pays for itself quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Cron tells a task &lt;strong&gt;when&lt;/strong&gt; to run. Airflow decides &lt;strong&gt;what&lt;/strong&gt; should run, &lt;strong&gt;in which order&lt;/strong&gt;, and &lt;strong&gt;what happens if something fails&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That difference becomes critical the moment your workflows grow beyond "one script, one schedule."&lt;/p&gt;

&lt;p&gt;Do you still use cron for anything in production, or has everything moved to an orchestrator?&lt;/p&gt;

</description>
      <category>airflow</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>etl</category>
    </item>
    <item>
      <title>How SQL Actually Works Under the Hood — A Deep Dive Into Snowflake and Redshift</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:30:00 +0000</pubDate>
      <link>https://dev.to/maithreyan11/how-sql-actually-works-under-the-hood-a-deep-dive-into-snowflake-and-redshift-2434</link>
      <guid>https://dev.to/maithreyan11/how-sql-actually-works-under-the-hood-a-deep-dive-into-snowflake-and-redshift-2434</guid>
      <description>&lt;p&gt;Most people think SQL is fast because "the database is optimized." That's true, but it's such a shallow answer it's almost useless. If you've ever had a query run fine on a 50K-row staging table and crawl on a 40M-row production table with the exact same SQL, you already know the real answer is more interesting — and more mechanical — than "the engine is smart."&lt;/p&gt;

&lt;p&gt;This post breaks down what actually happens between you hitting Run and getting your rows back, specifically on Snowflake and Redshift, since the two take genuinely different architectural approaches to solving the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your SQL is a request, not an instruction
&lt;/h2&gt;

&lt;p&gt;The first thing to internalize: SQL is declarative. You describe &lt;em&gt;what&lt;/em&gt; you want, not &lt;em&gt;how&lt;/em&gt; to get it. The engine's query optimizer is the component that decides &lt;em&gt;how&lt;/em&gt;. It parses your query, checks statistics about your tables (row counts, value distributions, min/max ranges per partition), and evaluates multiple possible execution plans before picking the one it estimates will cost the least.&lt;/p&gt;

&lt;p&gt;This is why identical SQL can perform completely differently depending on data size, indexing, or even how recently statistics were refreshed. The query text never changes; the plan behind it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snowflake's architecture: separating storage, compute, and metadata
&lt;/h2&gt;

&lt;p&gt;Snowflake splits itself into three distinct layers, and understanding this split explains almost everything about why it behaves the way it does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Storage layer&lt;/strong&gt; — your data sits in cloud object storage (S3, Azure Blob, or GCS) as compressed, columnar &lt;strong&gt;micro-partitions&lt;/strong&gt;, typically 50–500MB of uncompressed data each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute layer&lt;/strong&gt; — "virtual warehouses" are independent MPP compute clusters you spin up to run queries. Multiple warehouses can query the same underlying data simultaneously without contending for resources, because compute is fully decoupled from storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services layer&lt;/strong&gt; — this is the part people forget about. It manages metadata, security, and — critically — the optimizer itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why Snowflake queries can be fast without indexes at all
&lt;/h3&gt;

&lt;p&gt;Snowflake doesn't use traditional indexes. Instead, every micro-partition carries metadata about the min/max values of each column stored inside it. When you filter with a &lt;code&gt;WHERE&lt;/code&gt; clause, Snowflake's optimizer uses this metadata to skip entire micro-partitions that can't possibly contain matching rows — a technique called &lt;strong&gt;partition pruning&lt;/strong&gt;. If your table is well-clustered on the columns you filter by often, this can mean scanning a tiny fraction of the actual data on disk, even on a table with billions of rows.&lt;/p&gt;

&lt;p&gt;Snowflake also processes data using &lt;strong&gt;vectorized execution&lt;/strong&gt; — operating on batches of column values at once rather than row-by-row — and worker nodes exchange data directly during joins, avoiding some of the shuffle-heavy overhead that plagues naive distributed joins.&lt;/p&gt;

&lt;h3&gt;
  
  
  The catch
&lt;/h3&gt;

&lt;p&gt;None of this is free if your data isn't organized well. If a table isn't clustered on the columns you actually filter by, pruning doesn't help much, and Snowflake falls back to scanning far more micro-partitions than necessary — which shows up directly in your compute cost, since Snowflake bills by warehouse runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redshift's architecture: a leader node and a fleet of workers
&lt;/h2&gt;

&lt;p&gt;Redshift takes a more classically MPP (massively parallel processing) approach, structured around actual physical clusters rather than fully decoupled layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leader node&lt;/strong&gt; — receives your query, parses it, builds the execution plan, and compiles the plan into executable code. It also coordinates communication with your SQL client and handles a small set of functions that run exclusively on the leader node itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute nodes&lt;/strong&gt; — each stores a slice of your table's data and executes its portion of the plan in parallel. Results get sent back to the leader node for final aggregation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why distribution keys and sort keys matter so much
&lt;/h3&gt;

&lt;p&gt;Because Redshift's compute nodes each own a physical slice of the data, &lt;em&gt;how that data is distributed across nodes&lt;/em&gt; directly determines whether a join can run locally on each node or requires shuffling rows across the network first. If you join two tables on a column that isn't the distribution key, Redshift has to redistribute rows across nodes to align matching keys before the join can proceed — this is often the single biggest hidden cost in a slow Redshift query.&lt;/p&gt;

&lt;p&gt;Sort keys work similarly to Snowflake's micro-partition metadata: Redshift tracks the min/max range of sorted columns per block, and can skip blocks that fall outside your filter range entirely — this is Redshift's version of partition pruning, and it depends entirely on your table being sorted on the columns you commonly filter by.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compiled code, not interpreted SQL
&lt;/h3&gt;

&lt;p&gt;One detail that surprises people: Redshift doesn't re-interpret your SQL text every time you run a query. The leader node &lt;strong&gt;compiles&lt;/strong&gt; the execution plan into actual executable code tailored to your specific query and schema, and that compiled code can be reused for repeated executions of similar queries — meaning stable, frequently-run queries genuinely get faster the more they're run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the two architectures actually diverge in practice
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Snowflake&lt;/th&gt;
&lt;th&gt;Redshift&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compute/storage&lt;/td&gt;
&lt;td&gt;Fully decoupled, independently scalable&lt;/td&gt;
&lt;td&gt;Coupled to node/cluster you provision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data organization&lt;/td&gt;
&lt;td&gt;Automatic micro-partitions + clustering&lt;/td&gt;
&lt;td&gt;Manual/auto distribution keys + sort keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pruning mechanism&lt;/td&gt;
&lt;td&gt;Micro-partition min/max metadata&lt;/td&gt;
&lt;td&gt;Sort key block ranges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Join cost driver&lt;/td&gt;
&lt;td&gt;Data co-location via clustering&lt;/td&gt;
&lt;td&gt;Distribution key alignment across nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency model&lt;/td&gt;
&lt;td&gt;Multiple independent virtual warehouses&lt;/td&gt;
&lt;td&gt;Shared cluster, workload manager (WLM) queues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query execution&lt;/td&gt;
&lt;td&gt;Vectorized, direct worker-to-worker exchange&lt;/td&gt;
&lt;td&gt;Compiled code per query, reused on repeat runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The part that actually matters for you as an engineer
&lt;/h2&gt;

&lt;p&gt;Neither engine's speed comes from magic — it comes from &lt;strong&gt;giving the optimizer good conditions to work with&lt;/strong&gt;. On Snowflake, that means understanding clustering and not fighting it with poorly organized load patterns. On Redshift, that means deliberately choosing distribution and sort keys that match your actual join and filter patterns, not just letting &lt;code&gt;DISTSTYLE AUTO&lt;/code&gt; guess forever.&lt;/p&gt;

&lt;p&gt;I've seen this play out directly: a query that ran in seconds on a small staging table took over 4 minutes in production. The SQL hadn't changed. What had changed was that the join column in the production table didn't match the distribution key, so Redshift redistributed rows across every node before it could even start the join — pure network and shuffle overhead, invisible in the query text itself. Aligning the distribution key to the join column brought it back down to seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;SQL performance isn't about clever syntax. It's a systems problem — the optimizer, the storage layout, the distribution of data across compute, and the statistics the engine has about your tables all matter more than how you phrase your &lt;code&gt;SELECT&lt;/code&gt;. Writing fast SQL means understanding what your specific engine needs to make a good decision, and then giving it that, deliberately, instead of hoping the optimizer figures it out on its own.&lt;/p&gt;

&lt;p&gt;Once you start thinking in terms of "what will the optimizer actually do with this," you stop debugging queries by rewriting SQL syntax and start debugging them by checking &lt;code&gt;EXPLAIN&lt;/code&gt;, clustering keys, and distribution keys — which is a completely different (and much more effective) way to work.&lt;/p&gt;

&lt;p&gt;What's the deepest "it wasn't the SQL, it was the architecture" bug you've run into on Snowflake or Redshift?&lt;/p&gt;

</description>
      <category>sql</category>
      <category>dataengineering</category>
      <category>snowflake</category>
      <category>redshift</category>
    </item>
    <item>
      <title>How I Stopped Recalculating the Same MAX(date) Subquery on Redshift</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Wed, 05 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/maithreyan11/how-i-stopped-recalculating-the-same-maxdate-subquery-on-redshift-j50</link>
      <guid>https://dev.to/maithreyan11/how-i-stopped-recalculating-the-same-maxdate-subquery-on-redshift-j50</guid>
      <description>&lt;p&gt;A subquery that looked perfectly fine on its own was quietly running dozens of times a day across our Redshift pipeline. Here's the story of how I found it, why it hurt specifically on Redshift, and the fix that made it a non-issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I had a query that needed the most recent record for a given entity — a fairly common pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a &lt;strong&gt;correlated subquery&lt;/strong&gt; — the inner &lt;code&gt;SELECT&lt;/code&gt; references the outer query's row (&lt;code&gt;e2.entity_id = events.entity_id&lt;/code&gt;), so it can't just run once. It worked, it was correct, and it was fast enough on its own. I moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it went wrong
&lt;/h2&gt;

&lt;p&gt;The problem wasn't this one query — it was that the same "latest record per entity" logic kept showing up downstream: a dashboard filter, a data quality check, a reconciliation job, a couple of reports. Each of those had its own copy of a similar &lt;code&gt;MAX(date)&lt;/code&gt; subquery.&lt;/p&gt;

&lt;p&gt;Individually, every query looked fine in isolation. Nobody flagged it in review because each query's runtime on its own was acceptable. The real cost only showed up in aggregate — the same underlying calculation was being recomputed from scratch every time something needed it, hitting the same table repeatedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hurts more on Redshift specifically
&lt;/h2&gt;

&lt;p&gt;This is where Redshift's architecture makes the problem worse than it might be elsewhere. Redshift is a columnar, MPP (massively parallel processing) engine, and correlated subqueries interact badly with that design for a few specific reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Limited parallelism.&lt;/strong&gt; Because a correlated subquery is logically re-evaluated per outer row, it constrains how much the query can be parallelized across compute nodes — you lose some of the benefit of Redshift's distributed architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-optimal query plans.&lt;/strong&gt; Redshift's optimizer has documented issues generating efficient plans for correlated subqueries, particularly with &lt;code&gt;EXISTS&lt;/code&gt; / &lt;code&gt;NOT EXISTS&lt;/code&gt; patterns — sometimes producing nested loop joins, which are the slowest join type Redshift supports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data movement across nodes.&lt;/strong&gt; If the subquery's join key isn't the table's distribution key, Redshift has to redistribute rows across the cluster to evaluate the correlation, adding network I/O on top of the recomputation cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS's own query design guidance is explicit about this: use a &lt;code&gt;CASE&lt;/code&gt; expression for complex aggregations instead of scanning the same table multiple times, and prefer subqueries only when they return a small result set (under roughly 200 rows) used purely as a filter — not as a repeated per-row calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Instead of repeating the subquery everywhere it was needed, I computed the max date once and joined it back to the base table as an extra column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_date&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things mattered for making this actually fast on Redshift, not just "correct":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The join key matches the table's distribution key.&lt;/strong&gt; When you join on the distribution key, Redshift can complete the join on each node in parallel without shuffling rows across the cluster. If &lt;code&gt;entity_id&lt;/code&gt; isn't your distribution key, this join can still trigger a redistribution step — worth checking with &lt;code&gt;EXPLAIN&lt;/code&gt; before assuming it's free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The result gets reused, not recalculated.&lt;/strong&gt; Now &lt;code&gt;max_date&lt;/code&gt; is a column that lives on the row. Any downstream query, dashboard filter, or reconciliation check just reads that column and compares (&lt;code&gt;event_date = max_date&lt;/code&gt;) instead of running its own version of the subquery.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  An alternative: window functions
&lt;/h2&gt;

&lt;p&gt;A window function version is also worth considering on Redshift, especially if you want to flag &lt;em&gt;which&lt;/em&gt; row is the latest, not just know the date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids a self-join entirely and often performs better than either the correlated subquery or the join approach on large tables, since Redshift can compute it as a single sort-and-scan rather than a join. Redshift performance guides specifically recommend window functions over self-joins as a general optimization pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Materializing beyond a single query
&lt;/h2&gt;

&lt;p&gt;If this value needs to be read very frequently across many downstream consumers, you have a few options on Redshift specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A CTE&lt;/strong&gt; if it's scoped to a single pipeline run — just be aware that Redshift sometimes rewrites &lt;code&gt;WITH&lt;/code&gt; clauses into temporary &lt;code&gt;volt_tt&lt;/code&gt; tables internally, which can add overhead on complex CTEs. Simpler joins or window functions sometimes outperform an equivalent CTE for this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A materialized view&lt;/strong&gt; if the underlying data doesn't change constantly — Redshift materialized views precompute the result set, which is ideal for a value like "max date per entity" that many queries read but few queries update. Note that Redshift disables automatic materialized view refresh by default (citing planning-time overhead), so you'd trigger a manual refresh as the last step of your ETL job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A physical column updated via your ETL job&lt;/strong&gt; if this needs extremely frequent reads and refresh timing is predictable — this trades some storage and write complexity for guaranteed fast reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Correlated subqueries repeated across multiple queries multiply their cost silently — nothing looks wrong until you count the total load, and Redshift's MPP architecture makes that cost worse due to limited parallelism and potential data redistribution&lt;/li&gt;
&lt;li&gt;Precomputing shared logic once — via a join on the distribution key, a window function, or a materialized view — turns N recalculations into one&lt;/li&gt;
&lt;li&gt;Always check the query plan with &lt;code&gt;EXPLAIN&lt;/code&gt; when in doubt. Don't assume a subquery, join, or window function is faster without testing against your actual table's distribution and sort keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you run into logic that looked fine solo but got expensive once it was reused across a Redshift pipeline? Curious how others have handled this.&lt;/p&gt;

</description>
      <category>redshift</category>
      <category>sql</category>
      <category>dataengineering</category>
      <category>aws</category>
    </item>
    <item>
      <title>Why CTE vs Subquery Matters More on Redshift Than Anywhere Else</title>
      <dc:creator>Maithreyan</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/maithreyan11/why-cte-vs-subquery-matters-more-on-redshift-than-anywhere-else-llm</link>
      <guid>https://dev.to/maithreyan11/why-cte-vs-subquery-matters-more-on-redshift-than-anywhere-else-llm</guid>
      <description>&lt;p&gt;On Snowflake or modern Postgres, CTE vs subquery barely matters for performance. On Redshift, it can matter a lot — and I learned this the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption I carried over
&lt;/h2&gt;

&lt;p&gt;I carried an assumption over from Snowflake: that the optimizer would treat a CTE and an equivalent subquery the same way. Redshift doesn't play by those rules. Redshift's query planner is derived from an older Postgres lineage, and it historically doesn't inline CTEs the way newer engines do — it can materialize them as a separate step before the outer query even runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters on an MPP engine
&lt;/h2&gt;

&lt;p&gt;This matters a lot on Redshift specifically because it's a columnar, MPP (massively parallel processing) engine. Materializing a CTE means writing an intermediate result set across the cluster's compute nodes before the next step even starts. On a small CTE, that's harmless. On a CTE scanning millions of rows from a big fact table, that's a real cost — extra I/O and redistribution across nodes that a well-written subquery or a join might avoid entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;I hit this on a Redshift ETL query pulling from a large events table. I had it as a CTE, referenced once in the outer query, nothing complex. When I flattened it into a subquery instead, the query planner pushed filters down earlier and pruned way more data before the expensive join — noticeably faster on that specific table.&lt;/p&gt;

&lt;h2&gt;
  
  
  My rule of thumb for Redshift now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use a subquery when it's referenced once and simple — let the planner push predicates down early&lt;/li&gt;
&lt;li&gt;Use a CTE mainly for readability on complex multi-step logic, but check &lt;code&gt;EXPLAIN&lt;/code&gt; before trusting it won't materialize unnecessarily&lt;/li&gt;
&lt;li&gt;Always check the query plan (&lt;code&gt;EXPLAIN&lt;/code&gt;) rather than assuming CTE behavior — Redshift doesn't guarantee the same optimizations as Snowflake or Postgres 12+&lt;/li&gt;
&lt;li&gt;For genuinely reused logic, a temp table often beats both if the CTE is being scanned multiple times&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Patterns that are "safe defaults" on one warehouse can be real performance traps on another. Always validate with &lt;code&gt;EXPLAIN&lt;/code&gt; before assuming.&lt;/p&gt;

&lt;p&gt;Anyone else been burned by carrying optimizer assumptions across warehouses?&lt;/p&gt;

</description>
      <category>redshift</category>
      <category>sql</category>
      <category>dataengineering</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
