writing/10 Aug 2026/13 min read

Migrating 500 links out of a shortener without breaking any of them

Moving link data is easy. Moving links is not, because a short URL that someone already shared is a promise you cannot renegotiate. The hard part of this migration is not the CSV; it is deciding what happens to the addresses you no longer control.

The uncomfortable truth, first

If your links live on someone else's domain — short.example/abc123 where you do not own short.example — you cannot migrate those URLs. You can recreate their destinations somewhere new, but every copy of the old URL in the wild keeps pointing at the old provider for as long as they choose to serve it.

That reframes the whole exercise. A migration is really three separate jobs:

A
Links you can replace. Anything you still control the distribution of: website buttons, email templates, documentation, social profiles. Update the source, retire the old link.
B
Links you cannot replace but can still redirect. Already sent in emails, posted in forums, embedded in slide decks. Keep the old account alive and point those links at the new ones.
C
Links you cannot change at all. Printed on packaging, a conference banner, a business card, a QR code on a poster. These are the ones that decide your timeline.

Category C is why you do not cancel the old subscription on migration day. Budget for running both in parallel, and treat the old account as an archive you keep paying for rather than a thing to switch off.

Step 1: get everything out

Export before you plan. You cannot decide what to migrate until you can see the whole inventory, and the export will contain surprises — links nobody remembers creating, duplicates pointing at the same destination, campaigns from three years ago.

Take everything available: slug, destination, title, tags, creation date, and lifetime click counts. Click history usually cannot move with you, and that is fine — export it as CSV for your own records rather than trying to import it somewhere.

Then do an inventory pass before touching the new system. Three questions per link: is it still in use, does the destination still resolve, and do you know where it was distributed. In a typical 500-link export, a third are dead campaigns and a handful point at 404s. Migrating them wastes quota and carries the mess forward.

# quick destination health check before you migrate anything
cut -d, -f2 export.csv | tail -n +2 | while read url; do
  code=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 10 "$url")
  echo "$code  $url"
done | sort | tee destination-health.txt

# anything 4xx or 5xx needs a decision before it moves
grep -E "^[45]" destination-health.txt

Step 2: map the columns, and mind the differences

Every shortener exports a slightly different shape. Mapping long_url to destination_url is trivial. The differences that bite are semantic.

Slug case sensitivity

Some services fold case, so Launch26 and launch26 are one link. c1k.me treats them as two. Migrating from a case-folding service into a case-sensitive one is safe; going the other way silently merges links, and the merge picks a winner you did not choose. Check for case-variant duplicates in your export before importing anywhere.

Slug character rules and length

c1k.me allows [A-Za-z0-9_-], 3 to 48 characters. Slugs from elsewhere may contain dots, slashes, unicode or emoji. Those cannot transfer as-is and need a deliberate renaming decision per link, which means they belong in category A or B above.

Reserved words

Every service reserves route names. A slug called api, app or pricing may be legal on the old service and refused on the new one. These surface during validation, not before, so do not be surprised by a handful of rejections you cannot resolve by retrying.

UTM parameters: baked in or separate

Some services store UTM fields separately and append them at redirect time; others store the final URL. If you migrate a baked-in URL into a system that also appends UTMs from its own fields, you get duplicates in the query string. Decide which representation you want and normalise the whole export to it before importing.

Redirect status

If your old service issued 301 permanent redirects, browsers and intermediaries have cached them, possibly for a long time. Those caches will keep sending people to the old destination after you change it. c1k.me uses 302 precisely to avoid creating that problem, but it cannot undo caches the old provider created.

Step 3: validate in chunks before writing anything

The wrong way to import 500 rows is one large request that either succeeds or fails. The right way is small chunks, validated first, applied second.

c1k.me's import endpoints are deliberately split for this. /imports/validate checks a chunk of at most 25 rows and mutates nothing, returning a per-row outcome. /imports/apply then writes it. Each chunk commits independently, so a connection dropped at row 300 leaves the first 300 in place rather than rolling back your afternoon.

const CHUNK = 25;
const results = [];

for (let i = 0; i < rows.length; i += CHUNK) {
  const chunk = rows.slice(i, i + CHUNK);

  // dry run: nothing is written
  const check = await post("/imports/validate", {
    on_existing_slug: "skip",
    rows: chunk
  });

  if (check.data.rejected > 0) {
    console.warn(`chunk ${i / CHUNK}: ${check.data.rejected} rejected`);
    check.data.rows
      .filter(r => r.outcome === "reject")
      .forEach(r => console.warn(`  row ${i + r.index}: ${r.error.code}`));
  }

  // stable key per chunk makes a retry after a timeout safe
  const applied = await post("/imports/apply", {
    on_existing_slug: "skip",
    rows: chunk
  }, { "Idempotency-Key": `migration-2026-09:chunk-${i / CHUNK}` });

  results.push(applied.data);
}

Validate the entire file before applying any of it, if you can afford the extra pass. Knowing that 7 rows out of 500 will fail, and why, is worth far more before you start than discovered halfway through.

Also check the arithmetic against your quota first. On a plan with 100 active links, importing 500 rows will not fit, and a good import refuses before writing rather than stopping at row 100 and leaving you in a half-migrated state. The answer is usually to import the archive as inactive and activate only what is live.

Step 4: decide about collisions before the tool asks

Three policies exist and each is correct in a different situation.

update
The file wins. Right when re-running a migration after fixing destinations in the source data. Dangerous if anyone has edited links in the new system since the last run.
skip
The existing link wins. The safe default for a resumed or repeated import. Nothing already in the system is touched.
reject
Any collision fails the whole file. Right for a first import into an empty organization, where a collision means your assumptions were wrong and you want to stop and look.

There is a fourth collision source people forget: duplicates within the file itself. Two rows claiming launch26, usually because the export contained a case variant or an old and a new version of the same campaign. Validation catches these and reports the conflicting row number; resolve them in the file rather than letting last-write-wins pick for you.

Step 5: the cutover

Nothing about the import changes where existing traffic goes. The cutover is a separate, slower phase, and it is where the old account earns its keep.

01

Repoint the old links at the new ones. On the old service, edit each destination to the new short URL. Visitors get two redirects instead of one, which costs milliseconds and preserves every old address. Do this before you change anything else.

02

Update the sources you control. Website, email templates, docs, social bios, signature blocks. Grep your repositories for the old domain; it appears in more places than anyone remembers.

03

Watch both dashboards for a full cycle. A month, ideally a quarter. Traffic still arriving at the old service tells you exactly which distribution channels you have not found yet — more reliably than any audit.

04

Retire only when the old traffic reaches zero and stays there. For anything printed, that may be never. Keeping a cheap account alive purely to serve category C links is a legitimate permanent cost, and much cheaper than a dead QR code on a thousand printed items.

Do not let the old account lapse for non-payment mid-migration. A lapsed shortener usually stops resolving immediately, and every category B and C link dies at once with no warning to the people holding them.

Afterwards: make the next migration easier

You will do this again, either because a provider shuts down or because your needs change. Three habits make the next one an afternoon instead of a project.

Export on a schedule and keep the files in version control, so you always hold a current inventory without asking anyone. Record where each link was distributed, in a tag or the title — this is the single piece of information no shortener stores and every migration needs. And favour your own domain for anything durable: a redirect on a domain you own is the only link you can truly migrate.

The short version

Export first and prune before importing. Watch for case-folding differences, reserved words and baked-in UTM parameters. Validate in chunks before applying, with a stable idempotency key per chunk. Pick a collision policy deliberately. Then repoint the old links at the new ones and keep the old account running far longer than feels necessary, because the links you cannot change are the ones that decide when you are finished.