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:
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.
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.
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.
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.
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.
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.
updateskiprejectThere 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.
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.
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.
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.
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.
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.
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.