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.