Selectively Migrating Slack History Across Workspaces
The company I was working at was going through a split. One division was spinning out into a new entity — new brand, new email domain, the works. While at it, they were also absorbing a team from another company that had been acquired earlier. Three separate identity namespaces, one destination Slack workspace, and nearly a decade of history spread across hundreds of channels.
Slack does offer proper workspace migration tooling — but that’s an Enterprise+ feature. Post-split, a cost-conscious team doesn’t exactly have an Enterprise+ budget. What you do have is the ability to export a workspace as a ZIP file, and it turns out that’s enough.
What’s in the export
Requesting a workspace export from the admin panel generates a ZIP. For a large, long-lived workspace, this is not instant — expect it to take hours and arrive as several gigabytes. Slack is not shy about what it stores.
The structure inside is consistent:
export.zip
├── channels.json
├── users.json
├── canvases.json
├── file_conversations.json
├── huddle_transcripts.json
├── integration_logs.json
├── lists.json
├── general/
│ ├── 2016-08-10.json
│ ├── 2016-08-11.json
│ └── ...
└── product-roadmap/
├── 2021-04-01.json
├── 2021-04-02.json
└── ...
channels.json is an array of channel objects — name, ID, creation date, topic,
whether it’s archived. users.json is the user roster, with profile data
including email addresses. Each channel gets its own directory, with message
history split into one JSON file per day. Those per-day message files don’t need
to be touched — the surgery is entirely in the top-level JSON files and which
channel folders I included.
The supplementary files (canvases, huddle_transcripts, etc.) are smaller and
not critical to the migration itself, but Slack expects them present. Keep them.
What Slack validates on import: channel names in channels.json must correspond
to directories in the archive. Users referenced in messages should exist in
users.json. Beyond that, the format is surprisingly tolerant of modification.
Filtering channels
The source workspace had accumulated channels since 2016 — team rooms, project sprints, off-topic noise, and buried in there, the subset of channels that actually belonged to the migrating division. Identifying those came down to naming conventions: a handful of prefixes that the relevant teams had stuck to, mostly.
I built the list using the channel name prefixes the teams had established:
jq -r '[.[].name?
| select(type=="string")
| select(test("^product-|^team-|^proj-"; "i"))
]
| sort[]' channels.json > channels.txt
Since archived channels would need special handling later, I extracted them separately at the same time:
jq -r '[.[]
| select(.is_archived == true)
| .name?
| select(type=="string")
| select(test("^product-|^team-|^proj-"; "i"))
]
| sort[]' channels.json > channels.archived.txt
That list comes back later. With channels.txt in hand, I trimmed channels.json
to the selected set:
jq --rawfile allowed channels.txt '
($allowed | split("\n") | map(select(length>0))) as $list
| [.[] | select(.name | IN($list[]))]
' channels.json > channels.updated.json
Remapping users
The migrating division was moving from @oldcorp.com to @newco.io. The
absorbed team was coming in from @partner.com, also landing at @newco.io.
But the source workspace had accumulated thousands of users over nearly a decade
— former employees, contractors, external collaborators. Of those, only a few
hundred were joining the new workspace. Everyone else was staying behind or
simply gone.
This doesn’t mean their messages are worthless. History is still history — a
decision made in 2019 by someone who left in 2021 is still useful context. I
only needed to map the users who were actually moving; everyone else stays
as-is in users.json and their messages import attributed to their original
profile (which won’t resolve to an active account in the new workspace, but the
text is there).
I put the mapping in a simple CSV:
old,new
alice.jones@oldcorp.com,alice.jones@newco.io
bob.smith@partner.com,bob.smith@newco.io
carol.white@oldcorp.com,carol.white@newco.io
I converted it to a JSON lookup map:
jq -Rn '
(input | split(",") | map(gsub("^\\s+|\\s+$"; "") | ascii_downcase)) as $h
| ($h | index("old")) as $oi
| ($h | index("new")) as $ni
| reduce inputs as $line ({};
($line | select(length>0) | split(",")) as $c
| ($c[$oi] // "" | gsub("^\\s+|\\s+$"; "") | ascii_downcase) as $old
| ($c[$ni] // "" | gsub("^\\s+|\\s+$"; "")) as $new
| if ($old != "" and $new != "") then . + { ($old): $new } else . end
)
' user_mapping.csv > user_mapping.json
Then applied it against users.json:
jq --slurpfile m user_mapping.json '
map(
if (.profile.email? | type == "string") then
(.profile.email | ascii_downcase) as $e
| if ($m[0][$e]? != null)
then .profile.email = $m[0][$e]
else .
end
else .
end
)
' users.json > users.updated.json
This patches only the emails present in the mapping and leaves everything else untouched. A thousand ghost profiles stay in the file; the import doesn’t care, and neither do we.
Reassembling the ZIP
The assembly step is almost anticlimactic after all that. Copy the patched JSON files, copy the relevant channel directories, zip it back up:
#!/bin/bash
WORK_DIR=_work
SRC_DIR="OldCorp Slack export Aug 5 2016 - Feb 1 2026"
rm -Rf $WORK_DIR
mkdir $WORK_DIR
cp {canvases,file_conversations,huddle_transcripts,integration_logs,lists}.json $WORK_DIR/
cp users.updated.json $WORK_DIR/users.json
cp channels.updated.json $WORK_DIR/channels.json
while IFS= read -r value; do
if [ -n "$value" ]; then
cp -R "$SRC_DIR/$value" "$WORK_DIR/"
fi
done < channels.txt
rm -f export.zip
(cd $WORK_DIR && zip -rq ../export.zip . -x "*.DS_Store" -x ".git/*" -x "*.zip")
The result is a ZIP that Slack’s importer accepts as a valid workspace export — because as far as it can tell, it is one. Upload it at Workspace Settings → Import/Export → Import.
Import options that matter
The importer presents a few choices that are easy to click past without reading.
Channel handling — Slack offers several strategies: create new channels, merge into existing ones by name, or skip duplicates. The destination was essentially fresh, so I went with “create new channels” and didn’t have to think about it. If you’re importing into a workspace that already has channels with overlapping names and you want the history folded in, that’s when merge earns its keep.
User handling — This one has consequences. With users not yet provisioned in the destination, I picked “Import messages, do not import users”. That preserves message history and attributes it to the right accounts once they exist (matched by email). The alternative — letting Slack import users from the export — creates ghost accounts with the old email addresses. After spending time remapping everything, that’s a mess worth avoiding.
The archived channel trap
We first noticed something was off when a handful of channels simply weren’t
there after an import. No errors during the process. Nothing in the logs. They
were just missing. Checking channels.json in the export, they all had
"is_archived": true.
Slack silently skips archived channels on import. The fix was to unmark them before building the ZIP:
jq '[.[] | .is_archived = false]' channels.updated.json > channels.unarchived.json
I used channels.unarchived.json in the assembly script in place of
channels.updated.json. Once the import completed and I verified the history
came through, I re-archived the relevant channels via the Slack API —
channels.archived.txt had the list.
Batching
Pushing a full decade of history into one ZIP timed out mid-import — partial state on the other end, no clear record of what made it through. Beyond timeouts, the ZIP itself can grow large enough that splitting becomes unavoidable regardless.
I tracked progress in two files: channels.txt (the full target list) and
channels.done.txt (already imported). Each run filtered out what had already
been processed:
jq \
--rawfile allowed channels.txt \
--rawfile done channels.done.txt '
($allowed | split("\n") | map(select(length>0))) as $allowList
| ($done | split("\n") | map(select(length>0))) as $doneList
| [
.[]
| select(.name | IN($allowList[]))
| select(.name as $n | ($doneList | index($n) | not))
]
' channels.json > channels.updated.json
After a successful batch, I’d append those channel names to channels.done.txt
and run again. The import takes a while to process — Slack doesn’t give instant
feedback — so I gave each batch time to settle and verified the result before
moving on to the next.
Revert isn’t a rollback
Slack does have a revert option if an import goes wrong. Think of it less as a rollback and more as a partial undo that also takes a while.
In one of our imports, all deactivated users from the source workspace ended up in the destination — thousands of them — despite “Import messages, do not import users” being selected. The revert rolled back the messages, but the ghost accounts stayed. Slack support couldn’t remove them either; it required escalation to their engineering team to sort out.
Whether that was a bug specific to our setup or something more general, I can’t say. What I do know is that I wouldn’t rely on the revert as a clean safety net. Verifying each batch before moving to the next is the only way to catch problems before they compound.
What this comes down to
The Slack export format is structured data dressed up as a ZIP file. jq handles
the manipulation — filtering, patching, remapping. The shell scripts are glue.
The code itself is not the interesting part.
This is not a clean solution. It’s a budget solution. If Enterprise+ is on the table, use it. If it’s not — and after a company split it often isn’t — this works. The scripting is a small fraction of the time spent. Most of it is preparing batches, uploading ZIPs, and waiting for Slack to process them, which it does slowly and without much feedback. And if something goes wrong — say, thousands of deactivated users appear despite you selecting otherwise — you’re waiting on Slack support to sort it out, during which time the import functionality is effectively frozen for you. That cleanup took weeks.
Free, but paid for in patience and attention in equal measure.