Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.
Turn a file of old and new URLs into published redirects in one run, test every one of them, and catch the old URLs that only differ by query string, which redirects alone cannot match.
Context
Context. The new site is built and the old site's URLs are mapped to new ones, by hand, from a crawl, or as a by-product of a content import (migrate a WordPress site's content). There are hundreds or thousands of lines. Creating them in the admin one by one is slow and error-prone; the REST API takes them in a loop. One class of old URL needs more than a redirect: URLs that differ only in their query string, such as WordPress's /?p=123, because a redirect matches the path alone.
Steps
Put the map in a file in the repository, source,target per line, paths only for the same domain (building the map).
Separate the query-string URLs into their own list: any source with a ? in it.
Import the rest with the script below, which creates each redirect published; an unpublished redirect does nothing.
Handle the query-string URLs in a template on the path they share, with the snippet below.
Clear the cache, because a page already stored at a source path is served before redirects are checked.
Test every line (testing every entry), on staging first, then on production on launch day.
The query-string URLs, handled in the template of the path they share. For WordPress's default links that is the home page, so this goes at the top of the home page's template, with one entry per old id:
{% set legacyId = _request.getParameter('p') %}
{% if legacyId %}
{% set legacyMap = {'123': '/blog/brewing-guide', '456': '/about', '789': '/blog/roasting'} %}
{% if legacyMap[legacyId] is defined %}{% do _response.redirect(legacyMap[legacyId], 301) %}{% endif %}
{% endif %}
Generate the map's lines from the same file the import reads, rather than typing them.
Why this and not that
A redirect matches only the path, and the visitor's query string is added to the target, so /?p=123 and /?p=456 are the same request as far as redirects are concerned (how matching works). Reading the parameter in the template is what tells them apart; it also declares p to the page cache, so the home page is stored once and each old id is redirected separately. Exact redirects scale to thousands; a handful of regular expressions would be shorter to write and is checked for every uncached request, which is why the import creates exact ones. Creating them published in the same call avoids the most common bulk-import mistake: a complete, correct map of redirects that do nothing.