{"componentChunkName":"component---src-templates-post-jsx","path":"/en/drupal-migrations-incrementales-high-water-property","result":{"data":{"markdownRemark":{"html":"<p>A migration that runs once, during a rebuild, gets the brute-force treatment: replay everything, read the report, start over. A migration that runs <strong>every day</strong> against a live source database is a different job entirely. Replaying 200,000 rows every night to actually import 300 changed ones costs CPU, costs I/O on the source database, and above all eats an execution window that eventually overflows.</p>\n<p>The mechanism the Migrate API provides for this is called <code class=\"language-text\">high_water_property</code>.</p>\n<h2>What the high water mark actually does</h2>\n<p>The principle: the migration remembers the <strong>highest value</strong> of the tracking field (typically a <code class=\"language-text\">changed</code> / <code class=\"language-text\">updated_at</code>) among the rows already processed. On the next run, it only picks up beyond that value.</p>\n<p>Concretely, on the <code class=\"language-text\">SqlBase</code> side:</p>\n<ol>\n<li>The source plugin adds a <code class=\"language-text\">field &gt; remembered_value</code> condition to the query.</li>\n<li>It adds an <code class=\"language-text\">ORDER BY</code> on that field — the marker's progression only makes sense if rows arrive in ascending order.</li>\n<li>The marker is saved as it goes, row by row, not at the end of the run. A migration interrupted at 60% therefore doesn't start over from zero.</li>\n</ol>\n<p>The value is not stored in the map table, but in the key/value store, collection <code class=\"language-text\">migrate:high_water</code>, indexed by migration ID. It is information held <strong>outside the map</strong>: that's what explains several of the surprises listed below.</p>\n<p>One point that is often misunderstood: the high water condition is not exclusive. It is combined with <code class=\"language-text\">OR</code> against rows absent from the map table and those flagged <code class=\"language-text\">STATUS_NEEDS_UPDATE</code>. A row that was never imported therefore still gets imported, even if its <code class=\"language-text\">changed</code> is old.</p>\n<h2>Configuration</h2>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">id</span><span class=\"token punctuation\">:</span> article_incremental\n<span class=\"token key atrule\">label</span><span class=\"token punctuation\">:</span> <span class=\"token string\">'Articles — incremental import'</span>\n<span class=\"token key atrule\">source</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">plugin</span><span class=\"token punctuation\">:</span> article_source\n  <span class=\"token key atrule\">high_water_property</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> changed</code></pre></div>\n<p>If the source query has joins and the column name is ambiguous, specify the table alias:</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\">  <span class=\"token key atrule\">high_water_property</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> changed\n    <span class=\"token key atrule\">alias</span><span class=\"token punctuation\">:</span> n</code></pre></div>\n<p>On the source plugin side, there is nothing to wire up: <code class=\"language-text\">SqlBase</code> applies the condition and the sort from this configuration. One thing remains to be done, outside Drupal:</p>\n<div class=\"gatsby-highlight\" data-language=\"sql\"><pre class=\"language-sql\"><code class=\"language-sql\"><span class=\"token keyword\">CREATE</span> <span class=\"token keyword\">INDEX</span> idx_node_changed <span class=\"token keyword\">ON</span> node <span class=\"token punctuation\">(</span>changed<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Without an index on the high water field, you replace a full scan with a full scan <strong>plus a sort</strong>. That's the first gain to lock in.</p>\n<h2>What it is not</h2>\n<table>\n<thead>\n<tr>\n<th>Mechanism</th>\n<th>What it does</th>\n<th>Cost</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code class=\"language-text\">high_water_property</code></td>\n<td>Filters the source query on <code class=\"language-text\">&gt; last processed value</code></td>\n<td>Near zero if the field is indexed</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">migrate:import --update</code></td>\n<td>Flags <strong>every</strong> already-imported row as \"needs update\" and replays everything</td>\n<td>Maximal</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">migrate:import --sync</code></td>\n<td>Compares source IDs to map IDs and <strong>deletes</strong> destinations whose source has disappeared</td>\n<td>Full read of the source</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">track_changes: true</code></td>\n<td>Computes a hash of each source row and compares it to the one in the map</td>\n<td>Full read + a hash per row</td>\n</tr>\n</tbody>\n</table>\n<p><code class=\"language-text\">track_changes</code> is the direct competitor. It detects more things — including a change in a joined table that doesn't bump the node's <code class=\"language-text\">changed</code> — but it pays for a full read of the source on every run. Simple rule: <code class=\"language-text\">high_water_property</code> when the source exposes a reliable modification date, <code class=\"language-text\">track_changes</code> when it doesn't.</p>\n<h2>The pitfalls</h2>\n<p><strong>1. Be careful combining it with <code class=\"language-text\">--sync</code>.</strong> <code class=\"language-text\">--sync</code> infers deletions by comparing the list of source IDs to the map. But high water is precisely what restricts the source query. The returned list becomes partial, and anything that doesn't show up can be treated as deleted. Test this on a copy before considering the two together — not in production on a Friday evening.</p>\n<p><strong>2. Deletions are not detected.</strong> High water only sees what moves \"upward\". Content deleted or unpublished on the source side never surfaces. It needs separate handling: a soft delete on the source side with an updated <code class=\"language-text\">changed</code>, or a dedicated periodic synchronization pass.</p>\n<p><strong>3. The comparison is strict (<code class=\"language-text\">&gt;</code>), not <code class=\"language-text\">&gt;=</code>.</strong> If several rows share the same <code class=\"language-text\">changed</code> value down to the second and the run stops in the middle of that batch, the rest of the batch is lost on the next run. On high-volume sources, plan a safety margin (restart from <code class=\"language-text\">high_water - 60s</code>) rather than trusting second-level granularity.</p>\n<p><strong>4. The marker advances even on skipped rows.</strong> A row discarded by a <code class=\"language-text\">MigrateSkipRowException</code> in <code class=\"language-text\">prepareRow()</code> doesn't block progression. If the reason for the skip disappears later (a reference value finally present), the row will not be picked up again: its <code class=\"language-text\">changed</code> is now below the marker.</p>\n<p><strong>5. Non-SQL sources: a functional gain, not a performance gain.</strong> For a JSON or CSV source via <code class=\"language-text\">migrate_plus</code>, the filtering happens in PHP in <code class=\"language-text\">SourcePluginBase::next()</code>, after fetching. You avoid useless writes to the destination, not the download or the parsing of the full feed.</p>\n<p><strong>6. The field must be monotonic.</strong> A <code class=\"language-text\">changed</code> that the source rewrites downward, or a reused auto-incremented ID, breaks the mechanism silently. Nothing is reported as an error: rows simply stop being imported.</p>\n<p><strong>7. Rollback and high water are two distinct things.</strong> The map table is purged by the rollback, the key/value not necessarily. A migration that is rolled back and then relaunched can therefore reimport nothing at all. It's the classic symptom of \"my migration doesn't do anything anymore\".</p>\n<h2>Inspecting and resetting the marker</h2>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token comment\"># Read the current value</span>\ndrush php:eval <span class=\"token string\">'var_dump(\\Drupal::keyValue(\"migrate:high_water\")->get(\"article_incremental\"));'</span>\n\n<span class=\"token comment\"># Reset (next run = full import)</span>\ndrush php:eval <span class=\"token string\">'\\Drupal::keyValue(\"migrate:high_water\")->delete(\"article_incremental\");'</span>\n\n<span class=\"token comment\"># Reposition on a specific date (targeted catch-up)</span>\ndrush php:eval <span class=\"token string\">'\\Drupal::keyValue(\"migrate:high_water\")->set(\"article_incremental\", strtotime(\"2026-08-01\"));'</span></code></pre></div>\n<p>The third case is the real production tool: after an incident, you don't replay everything, you move the marker back over the affected window.</p>\n<p>Not to be confused with <code class=\"language-text\">migrate:reset-status</code>, which unblocks a migration stuck in <code class=\"language-text\">Importing</code> status after a kill, and has no effect whatsoever on high water.</p>\n<h2>Checklist before going to production</h2>\n<ul>\n<li>The high water field is indexed on the source side.</li>\n<li>It is monotonic and updated by <strong>every</strong> application write, including batches and third-party imports.</li>\n<li>The deletion case is handled by an explicit, documented mechanism.</li>\n<li>The catch-up procedure (repositioning the marker) is written down somewhere, not held in one person's head.</li>\n<li>The daily run is monitored on the <strong>number of rows processed</strong>, not just on its exit code: a broken incremental migration succeeds perfectly well at importing zero rows.</li>\n</ul>","excerpt":"A migration that runs once, during a rebuild, gets the brute-force treatment: replay everything, read the report, start over. A migration that runs every day…","frontmatter":{"date":"2026-08-20","metaDate":"2026-08-20","title":"Incremental Migrations in Drupal: Using high_water_property Properly","tags":["Drupal","Drupal 11","Migrate","PHP","Performance"],"path":"/drupal-migrations-incrementales-high-water-property","cover":{"childImageSharp":{"fluid":{"base64":"data:image/jpeg;base64,/9j/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wgARCAALABQDASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAAIDAQX/xAAXAQADAQAAAAAAAAAAAAAAAAAAAgME/9oADAMBAAIQAxAAAAHlvKmuSGA3/8QAGBAAAgMAAAAAAAAAAAAAAAAAAAEQETH/2gAIAQEAAQUCErHs/wD/xAAVEQEBAAAAAAAAAAAAAAAAAAABEP/aAAgBAwEBPwFn/8QAFBEBAAAAAAAAAAAAAAAAAAAAEP/aAAgBAgEBPwE//8QAFBABAAAAAAAAAAAAAAAAAAAAIP/aAAgBAQAGPwJf/8QAFxABAQEBAAAAAAAAAAAAAAAAAQAhMf/aAAgBAQABPyE4sOIw5Cku3//aAAwDAQACAAMAAAAQbB//xAAXEQEBAQEAAAAAAAAAAAAAAAABADFB/9oACAEDAQE/EA8jL//EABYRAQEBAAAAAAAAAAAAAAAAAAEQIf/aAAgBAgEBPxAdn//EABkQAQEBAQEBAAAAAAAAAAAAAAEAESExQf/aAAgBAQABPxA6oXPJDwNgg+SdGspxmV2//9k=","aspectRatio":1.7777777777777777,"src":"/static/23d58d98a1703412cd6d666e78dfca55/88110/cover.jpg","srcSet":"/static/23d58d98a1703412cd6d666e78dfca55/0b320/cover.jpg 480w,\n/static/23d58d98a1703412cd6d666e78dfca55/60b32/cover.jpg 960w,\n/static/23d58d98a1703412cd6d666e78dfca55/88110/cover.jpg 1920w,\n/static/23d58d98a1703412cd6d666e78dfca55/40175/cover.jpg 2880w,\n/static/23d58d98a1703412cd6d666e78dfca55/e58c2/cover.jpg 3840w,\n/static/23d58d98a1703412cd6d666e78dfca55/e742d/cover.jpg 5120w","srcWebp":"/static/23d58d98a1703412cd6d666e78dfca55/d1a9d/cover.webp","srcSetWebp":"/static/23d58d98a1703412cd6d666e78dfca55/bc3bf/cover.webp 480w,\n/static/23d58d98a1703412cd6d666e78dfca55/39337/cover.webp 960w,\n/static/23d58d98a1703412cd6d666e78dfca55/d1a9d/cover.webp 1920w,\n/static/23d58d98a1703412cd6d666e78dfca55/fcbe1/cover.webp 2880w,\n/static/23d58d98a1703412cd6d666e78dfca55/c136d/cover.webp 3840w,\n/static/23d58d98a1703412cd6d666e78dfca55/39b6f/cover.webp 5120w","sizes":"(max-width: 1920px) 100vw, 1920px"},"resize":{"src":"/static/23d58d98a1703412cd6d666e78dfca55/c4f3a/cover.jpg"}}}}}},"pageContext":{"isCreatedByStatefulCreatePages":false,"pathSlug":"/drupal-migrations-incrementales-high-water-property","locale":"en","prev":{"fields":{"locale":"en"},"frontmatter":{"path":"/post-mortem-cpu-spike-100-production-drupal","title":"Post-mortem: CPU Spike to 100% in Drupal Production — Analysis and Resolution","tags":["Drupal","Production","Performance","Post-mortem","New Relic","Cloudflare","Bot Traffic","Incident","Drupal 11"]}},"next":null}}}