<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Comparison · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/shape/comparison/</link>
    <description>Answers to PowerShell, Windows Server and OpenSSH questions the docs skip, with the reasoning behind them. Answer first, discussion after.</description>
    <language>en-us</language>
    
    <copyright>© 2026 Emmanuel Tsouris</copyright>
    <lastBuildDate>Mon, 07 Sep 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/shape/comparison/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>AWS CLI sync vs. Write-S3Object: What Changes on the Second Run</title>
      <link>https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Write-S3Object is wrong for repeat runs into a versioned bucket, which keeps every copy; sync is wrong when a restored file keeps its size and old timestamp.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p><code>aws s3 sync</code> compares a source and destination before selecting transfers.
<code>Write-S3Object -Folder -Recurse</code> uploads every file in the folder hierarchy
on every run; recursion adds subfolders, not a destination comparison or
deletion pass.</p>
<p>Keep <code>sync</code> when your script depends on skipping files that already match. Use
<code>Write-S3Object</code> when uploading every selected file is the intended operation.
Changing tools also means changing error handling: native exit codes for the
CLI, PowerShell errors for the cmdlet.</p>

</div>

<p>The refactor looks harmless. Replace one external command with a PowerShell
cmdlet, run it, and confirm that the files appear in S3.</p>
<p>The first run proves little. The second run is where the behaviors separate.</p>
<h2 id="two-commands-that-can-produce-the-same-first-upload">Two commands that can produce the same first upload<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#two-commands-that-can-produce-the-same-first-upload"
     aria-label="Link to section: Two commands that can produce the same first upload" data-pagefind-ignore></a>
</h2>
<p>The following are alternatives, run from PowerShell. They assume an existing
source folder, a bucket you control, and configured AWS access. Replace the
bucket placeholder. The first needs AWS CLI; the second needs <code>AWS.Tools.S3</code>.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Source</span> <span class="p">=</span> <span class="s1">&#39;C:\Data\exports&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Bucket</span> <span class="p">=</span> <span class="s1">&#39;YOUR-BUCKET&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="vm">$null</span> <span class="p">=</span> <span class="nb">Get-Command</span> <span class="n">aws</span> <span class="n">-CommandType</span> <span class="n">Application</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="n">aws</span> <span class="n">s3</span> <span class="n">sync</span> <span class="nv">$Source</span> <span class="s2">&#34;s3://</span><span class="nv">$Bucket</span><span class="s2">/exports/&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;S3 sync exited with code </span><span class="nv">$Code</span><span class="s2">&#34;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Source</span> <span class="p">=</span> <span class="s1">&#39;C:\Data\exports&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Bucket</span> <span class="p">=</span> <span class="s1">&#39;YOUR-BUCKET&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Folder</span> <span class="nv">$Source</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-KeyPrefix</span> <span class="s1">&#39;exports/&#39;</span> <span class="n">-Recurse</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span></span></span></code></pre></div></div>
  </div>
</div><p>For a source containing <code>report.json</code> and <code>daily\summary.csv</code>, the intended keys
are <code>exports/report.json</code> and <code>exports/daily/summary.csv</code>. Keep the prefix fixed
when comparing repeated runs; a new prefix gives either command an empty
destination namespace.</p>
<p>AWS describes the cmdlet as an uploader for files, content, or folder trees.
<code>-Recurse</code> includes subfolders. It doesn&rsquo;t implement the CLI&rsquo;s sync algorithm.
See the <a href="https://docs.aws.amazon.com/powershell/v5/reference/items/Write-S3Object.html" rel="external noopener" target="_blank">Write-S3Object reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<h2 id="what-each-command-does-on-a-repeat-run">What each command does on a repeat run<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#what-each-command-does-on-a-repeat-run"
     aria-label="Link to section: What each command does on a repeat run" data-pagefind-ignore></a>
</h2>
<div class="table-wrap" data-scrollable data-label="Table: Behavior">
  <table>
    <thead>
      <tr><th scope="col">Behavior</th><th scope="col"><code>aws s3 sync</code></th><th scope="col"><code>Write-S3Object -Folder -Recurse</code></th></tr>
    </thead>
    <tbody>
      <tr><td>Select transfers by comparing destination</td><td>Yes</td><td>No sync comparison</td></tr>
      <tr><td>Upload an unchanged selected file again</td><td>Usually skipped by comparison</td><td>Uploaded again</td></tr>
      <tr><td>Traverse subfolders</td><td>Built in</td><td>Requires <code>-Recurse</code></td></tr>
      <tr><td>Remove destination-only objects</td><td>Only with <code>--delete</code></td><td>No reconciliation of deletions</td></tr>
      <tr><td>Preview a sync plan</td><td><code>--dryrun</code></td><td>No equivalent sync plan</td></tr>
      <tr><td>Report failure to PowerShell</td><td>Native exit code</td><td>PowerShell error</td></tr>
    </tbody>
  </table>
</div>
<p>For local-to-S3 sync, the default upload criteria are a missing destination
object, a different size, or a local modification time newer than the S3
object&rsquo;s last-modified time. That time is when the object was uploaded. It isn&rsquo;t a
byte-for-byte scan of every file. A same-size file restored or copied with its
original, older timestamp is skipped, and S3 keeps the stale bytes. <code>--size-only</code> discards the time criterion;
<code>--exact-timestamps</code> applies to S3-to-local sync. These rules and directions are
documented in the <a href="https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html" rel="external noopener" target="_blank">AWS CLI sync reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>This is the migration question: did your script promise to upload a collection
of files, or did it promise to transfer only what its comparison considers
changed? Similar-looking final keys don&rsquo;t answer that question.</p>
<h2 id="deletions-are-a-separate-decision">Deletions are a separate decision<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#deletions-are-a-separate-decision"
     aria-label="Link to section: Deletions are a separate decision" data-pagefind-ignore></a>
</h2>
<p>Deleting a local file doesn&rsquo;t remove its S3 object in either example above.
For the CLI, <code>--delete</code> explicitly adds destination cleanup. Preview that choice
before applying it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">aws</span> <span class="n">s3</span> <span class="n">sync</span> <span class="s1">&#39;C:\Data\exports&#39;</span> <span class="s1">&#39;s3://YOUR-BUCKET/exports/&#39;</span> <span class="p">-</span><span class="n">-delete</span> <span class="p">-</span><span class="n">-dryrun</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;Sync preview exited with code </span><span class="nv">$Code</span><span class="s2">&#34;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The preview reports proposed operations; it isn&rsquo;t proof that subsequent writes
or deletions will be authorized or succeed. Review the exact bucket and prefix
before removing <code>--dryrun</code>.</p>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>A destination prefix shared by unrelated jobs is a poor place for <code>--delete</code>.
A file absent from one job&rsquo;s source may still belong to another, and the
reconciliation will remove it. Give each source a deliberate namespace before
enabling deletion. In a versioned bucket the delete adds a delete marker, so
the object can be recovered until lifecycle rules expire its noncurrent
versions; in an unversioned bucket it&rsquo;s gone.</p>
</div><p>For backups, destination-only files create another issue: restoring all current
objects can resurrect files that were intentionally removed locally. Decide
whether you need a current mirror or recoverable snapshots before choosing how
to handle that case.</p>
<h2 id="repeated-uploads-in-a-versioned-bucket">Repeated uploads in a versioned bucket<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#repeated-uploads-in-a-versioned-bucket"
     aria-label="Link to section: Repeated uploads in a versioned bucket" data-pagefind-ignore></a>
</h2>
<p>In a bucket with versioning enabled, another successful overwrite creates
another object version. Each stored version is a complete object, not only the
changed bytes. AWS explains this in its
<a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html" rel="external noopener" target="_blank">S3 Versioning guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>Consequently, repeatedly uploading an unchanged tree can create redundant
versions as well as extra transfer work and requests. How much that matters
depends on your data and schedule. A small configuration directory and a large
export tree need different tradeoffs.</p>
<p>Conversely, a fresh timestamped prefix intentionally stores a new set of keys.
There&rsquo;s no previous tree there for <code>sync</code> to reuse. For that design, uploading
every file may be exactly what you want.</p>
<p>Neither approach makes a live folder an atomic snapshot. If several related
files change during the transfer, the destination can contain a mixture of
moments. Use an application-consistent export or stable staging directory when
a restore requires the files to agree with each other.</p>
<h2 id="preserve-the-failure-contract-when-changing-tools">Preserve the failure contract when changing tools<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#preserve-the-failure-contract-when-changing-tools"
     aria-label="Link to section: Preserve the failure contract when changing tools" data-pagefind-ignore></a>
</h2>
<p>The CLI example checks its result immediately. A final check after several
native commands can miss an earlier failure. AWS&rsquo;s
<a href="https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-returncodes.html" rel="external noopener" target="_blank">return-code documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
includes failed and skipped S3 transfers among nonzero outcomes.</p>
<p>For the cmdlet, add context and propagate the error if needed:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Folder</span> <span class="nv">$Source</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-KeyPrefix</span> <span class="s1">&#39;exports/&#39;</span> <span class="n">-Recurse</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Warning</span> <span class="p">(</span><span class="s2">&#34;Export upload failed: {0}&#34;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Don&rsquo;t leave an old <code>$LASTEXITCODE</code> check after the cmdlet. The separate
<a href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/">PowerShell error-handling article</a>
shows why that can report success for a failed upload.</p>
<h2 id="check-a-migration-before-trusting-the-replacement">Check a migration before trusting the replacement<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#check-a-migration-before-trusting-the-replacement"
     aria-label="Link to section: Check a migration before trusting the replacement" data-pagefind-ignore></a>
</h2>
<p>Use a disposable source folder and dedicated destination prefix:</p>
<ol>
<li>Upload two files and record their destination keys.</li>
<li>Repeat without editing either file; inspect actual uploads or object versions.</li>
<li>Change one file&rsquo;s size and repeat; check which objects are written.</li>
<li>Remove one local file; inspect the destination-only object.</li>
<li>Use a missing source path and verify the overall script fails.</li>
<li>Use a <code>-SearchPattern</code> that matches nothing and confirm the script notices.
<code>Write-S3Object</code> uploads nothing and reports no error.</li>
</ol>
<p>Those checks exercise the behavior a syntax translation can overlook. They also
make your deletion and repeat-run expectations explicit.</p>
<h2 id="when-each-is-the-wrong-choice">When each is the wrong choice<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#when-each-is-the-wrong-choice"
     aria-label="Link to section: When each is the wrong choice" data-pagefind-ignore></a>
</h2>
<p><code>aws s3 sync</code> is the wrong choice when:</p>
<ul>
<li>A file can come back with its size and an older timestamp, as a restore or
a copy that keeps times does. The comparison skips it, and the destination
keeps the old bytes.</li>
<li>You add <code>--delete</code> on a prefix that other jobs also write to. The
reconciliation removes their objects because they are absent from your
source.</li>
<li>Every run writes to a fresh prefix, such as a dated snapshot. There&rsquo;s
nothing there to compare against, so the comparison buys nothing and the
script still depends on the AWS CLI.</li>
</ul>
<p><code>Write-S3Object -Folder -Recurse</code> is the wrong choice when:</p>
<ul>
<li>The script relies on skipping files that haven&rsquo;t changed. Every run
uploads the whole tree again, and in a versioned bucket each upload is
another complete version.</li>
<li>The destination has to mirror the source, deletions included. The cmdlet
never removes destination-only objects, and adding that means owning
comparison rules, key mapping, filters, deletions and partial failures
yourself.</li>
<li>You need to see what a run will do before it runs. There&rsquo;s no equivalent
of <code>--dryrun</code>.</li>
</ul>
<p>Choose based on the repeat run and the restore you need.</p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
