<?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: Pedro Beethoven</title>
    <description>The latest articles on DEV Community by Pedro Beethoven (@pedroven).</description>
    <link>https://dev.to/pedroven</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%2F3808860%2Ff5de824e-b04e-4166-834f-5245550738e6.jpg</url>
      <title>DEV Community: Pedro Beethoven</title>
      <link>https://dev.to/pedroven</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pedroven"/>
    <language>en</language>
    <item>
      <title>COPY lambda will always read the data, right? Not really</title>
      <dc:creator>Pedro Beethoven</dc:creator>
      <pubDate>Wed, 19 Aug 2026 20:28:02 +0000</pubDate>
      <link>https://dev.to/pedroven/copy-lambda-will-always-read-the-data-right-not-really-3m26</link>
      <guid>https://dev.to/pedroven/copy-lambda-will-always-read-the-data-right-not-really-3m26</guid>
      <description>&lt;p&gt;When I started to write a lambda that issues a Redshift &lt;a href="https://docs.aws.amazon.com/redshift/latest/dg/r_COPY.html" rel="noopener noreferrer"&gt;&lt;code&gt;COPY&lt;/code&gt;&lt;/a&gt; I thought that by default the lambda would read the data it was copying, and that's not the case (even though it could be), so you could be copying duplicated data without ever noticing it, or burning retries on poisoned data. This post will try to explain the difference between control planes and data planes, and how an AWS COPY lambda that is triggered by the creation of &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html" rel="noopener noreferrer"&gt;SQS messages&lt;/a&gt; fits into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control planes and data planes
&lt;/h2&gt;

&lt;p&gt;To clarify, the term &lt;em&gt;plane&lt;/em&gt; refers to a layer of the architecture (originally a network one, and only conceptually) where some specific kind of task is performed, and its meaning is defined by the boundary that you are drawing.&lt;br&gt;
So the difference between control and data planes in general is the following:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Control plane&lt;/em&gt;: it's the decision maker, the one that decides where the data should go, and does the administrative work, the setup, the security policies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Data plane&lt;/em&gt;: this one carries out those decisions, moves the data from source to destination, and follows the rules determined by the control plane. It's the one that actually touches the data.&lt;/p&gt;

&lt;p&gt;AWS has a &lt;a href="https://docs.aws.amazon.com/whitepapers/latest/aws-fault-isolation-boundaries/control-planes-and-data-planes.html" rel="noopener noreferrer"&gt;different concept&lt;/a&gt;, because for them control and data plane can be differentiated inside the same service. For example, Lambda's control plane is &lt;code&gt;CreateFunction&lt;/code&gt;, and its data plane is &lt;code&gt;Invoke&lt;/code&gt;. In my context, these concepts involve the ingestion pipeline and not the service itself. The boundary I am drawing is basically whose memory the bytes pass through. In other words, by AWS definition this lambda does call a data-plane API, &lt;a href="https://docs.aws.amazon.com/redshift-data/latest/APIReference/API_ExecuteStatement.html" rel="noopener noreferrer"&gt;&lt;code&gt;redshift-data:ExecuteStatement&lt;/code&gt;&lt;/a&gt;, since Redshift is doing its primary job there and not a management call.&lt;/p&gt;

&lt;p&gt;In the ingestion pipeline, the COPY lambda seems at the beginning to be a data plane component, but when we look closer at the implementation we notice that the data is not read at all, because the lambda only tells Redshift to move it. It hands over a pointer (the &lt;a href="https://docs.aws.amazon.com/redshift/latest/dg/loading-data-files-using-manifest.html" rel="noopener noreferrer"&gt;JSON manifests&lt;/a&gt;) and the bytes of the real data go to the Redshift cluster without even touching the lambda memory.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the code actually does
&lt;/h2&gt;

&lt;p&gt;To do a COPY operation that &lt;a href="https://docs.aws.amazon.com/redshift/latest/dg/c_best-practices-single-copy-command.html" rel="noopener noreferrer"&gt;batch loads the data&lt;/a&gt; into a Redshift cluster we don't need to read the data at all, and reading it would only give the overall lambda more points of failure. Here is a simple implementation of it, with some error handling and configuration loading left out:&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;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redshift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redshift-data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MAX_POLL_ATTEMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="n"&gt;POLL_INTERVAL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;  &lt;span class="c1"&gt;# SQS messages announcing files, not the files
&lt;/span&gt;        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;itemIdentifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messageId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;

    &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mandatory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manifests/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time_ns&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redshift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ClusterIdentifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLUSTER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COPY raw_events FROM &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s3://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BUCKET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; IAM_ROLE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IAM_ROLE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; FORMAT AS JSON &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; MANIFEST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUBMITTED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_POLL_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redshift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUBMITTED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PICKED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STARTED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;POLL_INTERVAL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FINISHED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# one COPY, so the whole batch goes back to the queue
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batchItemFailures&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;itemIdentifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messageId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batchItemFailures&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we look at the two clients created at the top we can see the whole point of this post, because this function only talks to S3 through &lt;code&gt;put_object&lt;/code&gt; and to Redshift through &lt;a href="https://docs.aws.amazon.com/redshift/latest/mgmt/data-api.html" rel="noopener noreferrer"&gt;&lt;code&gt;execute_statement&lt;/code&gt;&lt;/a&gt;, and there is no &lt;code&gt;get_object&lt;/code&gt; anywhere and no third client. The urls that come inside the message are copied into the manifest as strings and are never opened, so the lambda only writes down where the data is and asks Redshift to go and get it.&lt;/p&gt;

&lt;p&gt;As we can see we produce a single manifest based on all the records of the SQS event, but if for some reason (like that one that I will explain in the next section) any of the messages is re-enqueued to SQS we could face duplication issues, since by nature the COPY statement is not idempotent. And since the COPY is done over a manifest, when the message comes back the whole manifest is copied again, so a retry duplicates a batch and not only one record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What could break
&lt;/h2&gt;

&lt;p&gt;The code above never validates the data. It validates the message that points to it, which is not the same thing. The records are basically references to the real data, so if there are some errors in there, the COPY will fail on Redshift's side and the lambda has no way of knowing about it beforehand. This means that the COPY statement is the first schema-&lt;em&gt;enforcing&lt;/em&gt; read, and nothing checks those bytes against the destination schema until COPY does it.&lt;/p&gt;

&lt;p&gt;Also, without a proper handling of it in a different lambda or a proper configuration of the DLQ (dead-letter queue), the same poison message could go back to the SQS queue again and again, resulting in the same error until the message simply expires. Let's imagine a scenario where we have a visibility timeout of 15 minutes and a redrive policy with a &lt;code&gt;maxReceiveCount&lt;/code&gt; of 5. The visibility timeout has to be that large because it needs to cover the whole invocation, including the time the lambda can spend polling, otherwise SQS would hand the same message to a second invocation while the first one is still waiting for its COPY to finish.&lt;/p&gt;

&lt;p&gt;With those numbers the retry story goes like this. The poison message is received, the COPY fails, and 15 minutes later the message becomes visible again, and this repeats until the fifth receive, when SQS finally moves it to the DLQ, where it will wait for the retention period (up to 14 days) until somebody looks at it. Until these limits are reached, the message will return to this lambda and we will replicate the same errors.&lt;/p&gt;

&lt;p&gt;It is also worth saying that returning &lt;code&gt;batchItemFailures&lt;/code&gt; is what makes this bearable. With &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html" rel="noopener noreferrer"&gt;&lt;code&gt;ReportBatchItemFailures&lt;/code&gt;&lt;/a&gt; enabled on the event source mapping, only the messages listed in that return go back to the queue, and not the whole batch. The interesting part is that we can only be precise about half of it, because a message that is not valid JSON or that doesn't have the &lt;code&gt;urls&lt;/code&gt; key is something the lambda has in memory and can check, so we are able to send back exactly those ids. In the COPY context, this is a different story, since there is a single statement over a single manifest, and the smallest thing that can fail there is the whole batch, so one bad file inside it sends every message back and on the retry every good file of that batch is loaded again. In other words, the lambda can be specific about the messages that it reads, and can only be generic about the data that it never touches.&lt;/p&gt;

&lt;p&gt;The poll loop has a similar problem. When &lt;code&gt;MAX_POLL_ATTEMPTS&lt;/code&gt; runs out we stop watching the statement, but stopping the watch doesn't stop the COPY, and it keeps running on the cluster. The messages go back to the queue, the next invocation writes a new manifest with the same urls, and we end up with two statements loading the same files. We could call &lt;code&gt;cancel_statement&lt;/code&gt; in that branch, but this would only help while the COPY is still running, and by the time the poll gives up it could already have loaded everything, so the duplicate would happen anyway.&lt;/p&gt;

&lt;p&gt;Another scenario is sending the message without thinking about idempotency. Redshift doesn't enforce primary keys or unique constraints, so if the same message is delivered twice (and with a standard SQS queue, at-least-once delivery means it can be), we could have two correct messages and duplicated entries on Redshift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Possible fixes
&lt;/h2&gt;

&lt;p&gt;In order to fix the errors described above we can develop some types of solution. For example, for avoiding poisoned messages, we should have some other lambda that will validate them before they reach SQS. Another option is to validate them on the same COPY lambda, which to me is not a good solution, since it would imply downloading the real data from the source, and it would drag the lambda onto the data path and detour it from its original goal.&lt;/p&gt;

&lt;p&gt;For the idempotency issue, again we can have another lambda before this one to be responsible for creating some idempotency keys and passing this information to this lambda, or we can also do this work here, but again it will be another case of a component with multiple responsibilities (data plane and control plane at the same time), which is not the desired behavior. For this case the best solution is probably letting a Redshift statement be responsible for guaranteeing idempotence by discarding the duplicates. The query below, for example, can run as a model in an analytics tool like &lt;a href="https://www.getdbt.com/product/what-is-dbt" rel="noopener noreferrer"&gt;dbt&lt;/a&gt;, and everything downstream would read that model instead of reading the raw table:&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;with&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="k"&gt;as&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;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;loaded_at&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;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&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;loaded_at&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;duplicate_rank&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;raw_events&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;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loaded_at&lt;/span&gt;
&lt;span class="k"&gt;from&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;duplicate_rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deduplication key here is the hash of the whole payload, which already carries the identifier of the event inside it, &lt;code&gt;row_number()&lt;/code&gt; numbers the rows inside each group of identical rows, and keeping only the rank 1 collapses each group into a single row. It is important to notice that this one keeps one row per group, because the variant that filters by &lt;code&gt;count(*) over (partition by ...) = 1&lt;/code&gt; looks very similar but it throws the whole duplicated group away, losing the record instead of deduplicating it. The &lt;code&gt;order by&lt;/code&gt; only decides which copy survives, and since the rows are identical it doesn't matter which one it is.&lt;/p&gt;

&lt;p&gt;The most attentive readers have probably already noticed that the COPY itself is still not idempotent, but since we are thinking in the context of an overall implementation of the pipeline, the final goal is to guarantee that whatever reads the data downstream doesn't see duplicated rows. Since there is always the need to run the statement above we can say that this pipeline is kind of eventually idempotent, but the call of the lambda itself is not. The word eventually is important here, because the duplicated rows are still physically in the raw table, and anything that queries it before the deduplication runs will still see both copies. So the window where the data is wrong is the interval between the COPY and the next run of that model, which means that this window is not defined by the lambda but by the schedule that deduplication tool is running on. And anything that decides to read the raw table directly instead of the model is not covered by this at all.&lt;/p&gt;

&lt;p&gt;There is also a second thing that this design does not guarantee. Since the key is a hash of the payload, two events that are really distinct but happen to be identical are indistinguishable from a redelivery, so the deduplication will drop one of them. I accepted this because in this pipeline an identical duplicate is always a retry, but this is an assumption about the data and not a property of the code, so if one day this stops being true the statement will silently discard real rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This COPY lambda was an example to me that even a simple lambda function responsible only for creating manifests and copying data can lead to wrong assumptions if you don't look into more details, and can also introduce a kind of error that a simple look into the happy path can hide. Also, the same lambda can handle the validation of malformed data or the deduplication, but this will contradict the single responsibility principle (becoming a control and data plane at the same time). The validation fits better in the function that writes the file, because that one already has the bytes in memory and checking them there doesn't cost anything extra, while doing it here would mean downloading the same data a second time only to look at it. So having a clear vision of each step of the function and a solution to each fault scenario can improve the chances of the overall implementation not becoming a complete failure.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>dataengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How one Django .delete() ran a 4 GB instance out of memory | Slicing Prod Data</title>
      <dc:creator>Pedro Beethoven</dc:creator>
      <pubDate>Tue, 11 Aug 2026 16:42:23 +0000</pubDate>
      <link>https://dev.to/pedroven/how-one-django-delete-ran-a-4-gb-instance-out-of-memory-slicing-prod-data-330m</link>
      <guid>https://dev.to/pedroven/how-one-django-delete-ran-a-4-gb-instance-out-of-memory-slicing-prod-data-330m</guid>
      <description>&lt;p&gt;ORMs can bring us a lot of simplicity and are easier to maintain than raw SQL commands, but as with everything in life it comes with a cost, in my case the cost was memory, more precisely an OOM (Out Of Memory) error, so in order to register this for the future and also help people to avoid the same mistakes, I decided to write more about it, its causes and possible solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;The context in which the OOM occurred was a transform step of a pipeline that has the goal of generating a slice of production data. I know that today there are some tools to implement that, but we had some specific requirements (like leaving some rows of some tables untouched), and in order to use the same models already defined in Django and use them to simplify maintenance, we decided to use a Django command.&lt;/p&gt;

&lt;p&gt;To create the slice, we decided to choose a percentage and delete the rest, but the problem is that the first delete involves a cascade of &lt;a href="https://docs.djangoproject.com/en/5.2/ref/models/fields/#django.db.models.ForeignKey.on_delete" rel="noopener noreferrer"&gt;&lt;code&gt;CASCADE&lt;/code&gt;&lt;/a&gt; rules (yes) by the use of the &lt;a href="https://docs.djangoproject.com/en/5.2/ref/models/querysets/#delete" rel="noopener noreferrer"&gt;&lt;code&gt;.delete()&lt;/code&gt;&lt;/a&gt; method. This method (and other implementation mistakes) was the cause of the OOM error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;Well, saying that the cause of the error was exclusively about the method is not the whole truth, since this only appeared once I made the first QA test with production data, and this is the cause of most backend issues: Scale and Volume. Locally, I tried to make a good seed of data to test my transform step, but none of it was enough to represent the prod data (more than 150 GB). On top of that, in production the data is much more concentrated in the leaves than in the root: to give a sense of scale (numbers illustrative), the shape looks roughly like grandfather → 20k parents → 100k grandchildren. This created a perfect scenario to expose my implementation error: the wrong use of &lt;code&gt;.delete()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How delete works
&lt;/h2&gt;

&lt;p&gt;When you call &lt;code&gt;.delete()&lt;/code&gt; on a queryset, the real work is handed off to a &lt;a href="https://github.com/django/django/blob/5.2.13/django/db/models/deletion.py#L94" rel="noopener noreferrer"&gt;&lt;code&gt;Collector&lt;/code&gt;&lt;/a&gt; instance. Its job is to work out &lt;em&gt;everything&lt;/em&gt; that has to be deleted before deleting anything: it recursively &lt;a href="https://github.com/django/django/blob/5.2.13/django/db/models/deletion.py#L244" rel="noopener noreferrer"&gt;&lt;code&gt;collect()&lt;/code&gt;&lt;/a&gt;s the base model's cascade relationships, pulling the affected objects into an in-memory dict, and only then issues the actual SQL, one &lt;code&gt;DELETE ... IN (...)&lt;/code&gt; per table. (It can skip loading a relation when it's able to "fast delete" it with a single subquery, but anything with its own cascades, signals, or children still gets pulled in.) That "collect the whole tree first, delete second" design is exactly where the memory goes. Three parts of the &lt;code&gt;Collector&lt;/code&gt; tell the story: where the objects are kept, how the collection recurses, and when the SQL finally runs.&lt;/p&gt;

&lt;p&gt;First, the store (all excerpts from &lt;a href="https://github.com/django/django/blob/5.2.13/django/db/models/deletion.py" rel="noopener noreferrer"&gt;&lt;code&gt;django/db/models/deletion.py&lt;/code&gt;&lt;/a&gt;, Django 5.2, trimmed for clarity). The objects to be deleted are accumulated in a single in-memory dict:&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="c1"&gt;# Initially, {model: {instances}}, later values become lists.
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# objects to be deleted are collected here, in memory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recursion comes from the &lt;code&gt;CASCADE&lt;/code&gt; handler: for each related object it finds, it calls &lt;code&gt;collect()&lt;/code&gt; again, which collects &lt;em&gt;their&lt;/em&gt; related objects, all the way down the tree:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub_objs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;          &lt;span class="c1"&gt;# recurse: collect the children of the children ...
&lt;/span&gt;        &lt;span class="n"&gt;sub_objs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote_field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;source_attr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fail_on_restricted&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;objs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
        &lt;span class="n"&gt;new_objs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;objs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;      &lt;span class="c1"&gt;# stash these instances in self.data
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;related&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;get_candidate_relations_to_delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;related&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;
            &lt;span class="n"&gt;on_delete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote_field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_delete&lt;/span&gt;
            &lt;span class="bp"&gt;...&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;sub_objs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;related_objects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;related_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fetch the children
&lt;/span&gt;                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lazy_sub_objs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;sub_objs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;on_delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub_objs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# CASCADE -&amp;gt; collect() again
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only once &lt;em&gt;everything&lt;/em&gt; is collected does the SQL run, one &lt;code&gt;DELETE ... IN (...)&lt;/code&gt; per model, built from the primary keys of every instance sitting in &lt;code&gt;self.data&lt;/code&gt;:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="c1"&gt;# delete instances
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instances&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DeleteQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;pk_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# every collected pk, still in memory
&lt;/span&gt;            &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk_list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# DELETE FROM ... WHERE pk IN (...)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the combination of the collect method being called for each cascade-related object and being put in memory by the collector instance, plus the volume of data, plus the structure of the distribution of the data, rapidly uses up the maximum memory capacity of the machine instance (4 GB to be exact).&lt;/p&gt;

&lt;h2&gt;
  
  
  The initial solution
&lt;/h2&gt;

&lt;p&gt;So the initial solution to this, instead of basically calling the queryset with the delete method, was to delete their related descendants in batches in order to keep memory usage constant, the classical idea of solving the small problems first. Here is the implementation:&lt;/p&gt;

&lt;p&gt;The models involved form a &lt;code&gt;Blog → Post → Comment&lt;/code&gt; (Fictional models) cascade (grandfather → parent → grandchild), where the volume lives in the leaves. We first compute the primary keys we want to drop with raw SQL (so the ids never all land in Python), and then, instead of one big &lt;code&gt;queryset.delete()&lt;/code&gt;, we delete them a bounded batch at a time:&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;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;

&lt;span class="c1"&gt;# The naive version: one call for the whole slice. queryset.delete() runs
# Django's Collector, which pulls the cascade descendants (Post -&amp;gt; Comment -&amp;gt; ...)
# into memory before issuing the DELETEs. On prod-scale data this is what
# exhausts a 4 GB instance.
&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;drop_ids&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# The batched version: delete the same rows a bounded batch at a time, so the
# Collector only ever holds one batch worth of descendants in memory.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batched_delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&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;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Delete through _base_manager (the unfiltered manager), not .objects:
&lt;/span&gt;    &lt;span class="c1"&gt;# a custom default manager can hide rows (e.g. soft-deleted ones) that
&lt;/span&gt;    &lt;span class="c1"&gt;# the id list still targets, which would silently under-delete.
&lt;/span&gt;    &lt;span class="n"&gt;manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_base_manager&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;batched_delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drop_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after the new implementation was merged, we tried a new round of QA, and the OOM error was fixed, so everything is good, right? Turns out, no. At this moment, even though I had some notion of the structure of the data, I wasn't expecting that these deletions using these defined batches of deletion would take more than 2 hours to (not) be processed, which caused the whole pipeline to time out and thus the need to redesign everything again to solve this new issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Even though this first try failed at the goal of solving all the problems of the pipeline and finally delivering it, it helped me to better understand how Django works in the background with its ORM. So, in the next post, I will try to explain what my tries to definitively solve this were (hopefully), as soon as possible.&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>debugging</category>
      <category>database</category>
    </item>
    <item>
      <title>How to Actually Tailor Your Resume Without Losing Your Mind</title>
      <dc:creator>Pedro Beethoven</dc:creator>
      <pubDate>Fri, 06 Mar 2026 01:45:52 +0000</pubDate>
      <link>https://dev.to/pedroven/how-to-actually-tailor-your-resume-without-losing-your-mind-24kf</link>
      <guid>https://dev.to/pedroven/how-to-actually-tailor-your-resume-without-losing-your-mind-24kf</guid>
      <description>&lt;p&gt;``Everyone says you should tailor your resume for every job application. And they're right. But nobody talks about how incredibly tedious that process actually is.&lt;/p&gt;

&lt;p&gt;I've been through it. Multiple times. And I've watched friends go through it too. The pattern is always the same. You find a job posting, get excited, open your resume, stare at it for twenty minutes, change two bullet points, and call it done. That's not tailoring. That's just editing with extra guilt.&lt;/p&gt;

&lt;p&gt;The problem isn't laziness. The problem is that doing it properly takes real effort, and when you're applying to dozens of jobs, that effort becomes unsustainable very fast.&lt;/p&gt;

&lt;p&gt;So let me share what I've learned about actually tailoring resumes in a way that works without burning out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a master resume, not a blank page
&lt;/h2&gt;

&lt;p&gt;This is the single most important thing I can tell you. Never start from scratch.&lt;/p&gt;

&lt;p&gt;Build one document that has everything. Every project, every skill, every achievement, every responsibility you've ever had. Make it ugly. Make it long. Nobody will ever see this document except you.&lt;/p&gt;

&lt;p&gt;This is your source of truth. When you need to tailor a resume for a specific position, you pull from this master document. You don't invent new things. You select the most relevant ones.&lt;/p&gt;

&lt;p&gt;The difference is huge. Instead of thinking "what should I write," you're thinking "what should I pick." That's a much easier decision to make, especially when you're tired and just want to get the application done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the job posting like a developer reads documentation
&lt;/h2&gt;

&lt;p&gt;Most people skim job postings. They look at the title, glance at the requirements, and move on. That's a mistake.&lt;/p&gt;

&lt;p&gt;Job postings are basically specifications. They tell you exactly what the company is looking for. The keywords they use, the order they list requirements, and the specific tools they mention. All of that matters.&lt;/p&gt;

&lt;p&gt;Here's what I do. I read the posting carefully and highlighted the key terms. Not just technical skills, but also the soft skills and the type of language they use. Suppose they say "fast-paced environment," that tells you something. If they say "collaborative team," that tells you something different.&lt;/p&gt;

&lt;p&gt;Then I look at my master resume and pick the experiences that match those terms most closely. I don't copy their words blindly, but I make sure the language in my resume resonates with what they're looking for.&lt;/p&gt;

&lt;p&gt;This isn't gaming the system. This is communication. You're making it easy for the person reading your resume to see the connection between what they need and what you offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ATS problem is real, but not in the way people think
&lt;/h2&gt;

&lt;p&gt;There's a lot of fear around Applicant Tracking Systems. People think these systems are AI overlords that reject resumes based on mysterious algorithms. The reality is simpler and also more annoying.&lt;/p&gt;

&lt;p&gt;Most ATS systems are basically keyword matchers. They parse your resume and check if certain terms appear. If the job posting asks for "Python" and your resume says "Python," that's a match. If you wrote "py" or "Python programming language" instead, it might not match.&lt;/p&gt;

&lt;p&gt;The fix is straightforward. Use the same terminology as the job posting. If they say "React," write "React." If they say "CI/CD," write "CI/CD." Don't get creative with naming conventions.&lt;/p&gt;

&lt;p&gt;But here's the part people miss. ATS is just the first filter. A human still reads your resume after that. So you need to optimize for both. Keywords for the machine, clear and compelling writing for the person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop listing responsibilities, start showing impact
&lt;/h2&gt;

&lt;p&gt;This is the most common resume mistake I see, and I made it myself for years.&lt;/p&gt;

&lt;p&gt;"Responsible for developing backend services" tells the reader nothing useful. Every backend developer is responsible for developing backend services. That's literally the job.&lt;/p&gt;

&lt;p&gt;What actually matters is what happened because of your work. Did response times improve? Did you reduce costs? Did you build something that other teams started using? Did you fix a bug that was causing customer complaints?&lt;/p&gt;

&lt;p&gt;Numbers help, but they're not mandatory. "Reduced API response time by 40%" is great. But "Built an internal tool that replaced a manual process the team had been doing for two years" is also compelling, even without a percentage.&lt;/p&gt;

&lt;p&gt;The point is to show that your work had consequences. That it mattered beyond just completing a task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the format boring
&lt;/h2&gt;

&lt;p&gt;I know this sounds counterintuitive, especially with all those beautiful resume templates floating around on the internet. But fancy formatting causes real problems.&lt;/p&gt;

&lt;p&gt;Complex layouts with columns, icons, and graphics often break when parsed by ATS systems. They also make it harder for recruiters to quickly scan your resume. And recruiters scan fast. You have maybe ten seconds of attention before they decide to keep reading or move on.&lt;/p&gt;

&lt;p&gt;Use a clean, single-column layout. Clear section headers. Consistent formatting. Standard fonts. It's not exciting, but it works.&lt;/p&gt;

&lt;p&gt;Save the creativity for your portfolio or your personal website. The resume is a functional document, not a design showcase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "one page" rule is mostly right
&lt;/h2&gt;

&lt;p&gt;For most people with less than ten years of experience, one page is enough. Not because there's some sacred rule about it, but because forcing yourself to fit everything on one page makes you prioritize.&lt;/p&gt;

&lt;p&gt;If you can't fit everything, that's the point. You shouldn't be including everything. You should be including the things that matter most for this specific application.&lt;/p&gt;

&lt;p&gt;Two pages become acceptable when you genuinely have enough relevant experience to justify them. But "relevant" is the keyword. Ten years of experience doesn't mean ten years of content. It means you have more to choose from when tailoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailoring at scale is the real challenge
&lt;/h2&gt;

&lt;p&gt;Here's the honest part. Everything I described above works great when you're applying to five jobs. It becomes exhausting when you're applying to fifty.&lt;/p&gt;

&lt;p&gt;And most people looking for jobs are applying to far more than five positions. The math just doesn't work. If each tailored resume takes thirty minutes, and you're applying to ten jobs a week, that's five hours just on resume customization. On top of job searching, cover letters, and the actual interviews.&lt;/p&gt;

&lt;p&gt;This is exactly why I built &lt;a href="https://alapi.app" rel="noopener noreferrer"&gt;Alapi&lt;/a&gt;. Not because I think AI should write your resume for you. But because the mechanical parts of tailoring, matching keywords, adjusting emphasis, and reformatting can be automated, you can focus on the parts that actually require your brain.&lt;/p&gt;

&lt;p&gt;You upload your resume once. Alapi collects job opportunities from multiple platforms. When you find something interesting, it generates a tailored version of your resume for that specific position. You review it, edit whatever you want, and export it.&lt;/p&gt;

&lt;p&gt;It doesn't replace your judgment. It just removes the tedious parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable truth about job applications
&lt;/h2&gt;

&lt;p&gt;Tailoring your resume matters. But it's only one piece of a much larger and often frustrating process.&lt;/p&gt;

&lt;p&gt;The best resume in the world won't help if you're applying to jobs that aren't a good fit. And the job search process itself, with its ghosting and automated rejections and vague feedback, can be genuinely demoralizing.&lt;/p&gt;

&lt;p&gt;What I've learned, both from my own experience and from building a tool around this problem, is that the most important thing is to reduce friction wherever you can. Make the process as efficient as possible so you can sustain it over time without burning out.&lt;/p&gt;

&lt;p&gt;Because finding the right job isn't usually about one perfect application. It's about consistently putting yourself out there until the right match happens. And anything that makes that process less painful is worth trying.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you want to try Alapi for tailoring your resumes automatically, check it out at &lt;a href="https://alapi.app" rel="noopener noreferrer"&gt;alapi.app&lt;/a&gt;. It's free to start.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>resume</category>
      <category>career</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
