How to plan a migration to Swift 6

Published on: March 6, 2025

Swift 6 has been available to us for the better part of a year now, and more and more teams are considering or looking at migrating to the Swift 6 language mode. This typically involves trying to turn on the language mode or turning on strict concurrency, seeing a whole bunch of warnings or errors, and then deciding that today is not the day to proceed with this migration.

Today I would like to propose an approach to how you can plan your migration in a way that won’t scare you out of attempting the migration before you’ve even started.

Before you go through this entire post expecting me to tell you how to go to Swift 6 within a matter of days or weeks, I'm afraid I'm going to have to disappoint you.

Migrating to Swift 6, for a lot of apps, is going to be a very slow and lengthy process and it's really a process that you don't want to rush.

Taking an initial inventory

Before you start to migrate your codebase, I would highly recommend that you take inventory of the state of your codebase. This means that you should take a look at how modularized your codebase is, which dependencies you have in your codebase, and maybe most importantly how much concurrency you’re really using right now. Find out how often you’re explicitly, and purposefully you’re leaving the main thread. And try to understand how much of your code will run on the main thread.

You should also look at your team and figure out how up-to-date your team is, how comfortable they are with Swift concurrency already. In the end, the entire team will need to be able to work on and with your Swift 6 codebase.

On a code level, it's essential to understand how much concurrency you actually need because Swift concurrency is, by design, going to introduce a lot of concurrency into your app where maybe you don't actually need all of that concurrency. That’s why it’s so important for you to figure the amount of concurrency you’ll require beforehand by analyzing what you have now.

For example, if you have a view and you have a view model, and that view model talks to another layer, then probably you are doing most of the work on the main thread right now.

Once you hit your networking layer, your network calls will run somewhere else, and when your networking related functions invoke their callbacks, those will typically run on a background thread, and then you come back to the main thread to update your UI.

In this scenario, you don't need a lot of concurrency; in fact, I would say that you don't need concurrency beyond what URLSession provides at all. So once you’re adopting Swift Concurrency, you’ll want to understand how you can structure your code to not leave the main thread for every async call.

You might already have adopted async-await, and that's completely fine—it probably means that you do have more concurrency than you actually need. Every nonisolated async function that you write will, by default, run on a background thread. You don’t always need this; you’ll most likely want to explicitly isolate some of your work to the main actor to prevent leveraging concurrency in places where it’s simply not benefitting you.

You'll also want to make sure that you understand how dependent or how coupled your codebase is because the more coupling you have and the less abstractions and modularization you have, the more complexities you might run into. Understanding your codebase deeply is a prerequisite to moving to Swift 6.

Once you understand your codebase, you might want to look at modularizing. I would say this is the best option. It does make migrating a little bit easier.

So let's talk about modularization next.

Modularizing your codebase

When you migrate to Swift 6, you'll find that a lot of objects in your code are being passed from one place to another, and when you start to introduce concurrency in one part of the code, you’re essentially forced to migrate anything that depends on that part of your codebase.

Having a modularized codebase means that you can take your codebase and migrate it over time. You can move component by component, rather than being forced to move everything all at once.

You can use features like @preconcurrency to make sure that your app can still use your Swift 6 modules without running into all kinds of isolation or sendability warnings until your app also opts in to strict concurrency.

If you don't want to modularize your codebase or you feel your codebase is way too small to be modularized, that's completely fine. I'm just saying that the smaller your components are, the easier your migration is going to be.

Once you know the state your codebase is in and you feel comfortable with how everything is, it's time to turn on strict concurrency checks.

Turning on strict concurrency checks

Before you turn on Swift 6 language mode, it is recommended to turn on strict concurrency checking for the modules that you want to migrate. You can do this for both SPM and in Xcode for your app target.

I would recommend to do this on a module by module basis.

So if you want to refactor your models package first, turn on strict concurrency checks for your model package, but not yet for your app. Turning on strict concurrency for only one module allows you to work on that package without forcing your app to opt into all of the sendability and isolation checks related to the package you’re refactoring.

Being able to migrate one package at a time is super useful because it makes everything a lot easier to reason about since you’re reasoning about smaller bits of your code.

Once you have your strict concurrency checks turned on you're going to see a whole bunch of warnings for the packages and targets where you've enabled strict concurrency and you can start solving them. For example, it’s likely that you'll run into issues like main actor isolated objects to sendable closures.

You'll want to make sure that you understand these errors before you proceed.

You want to make sure that all of your warnings are resolved before you turn on Swift 6 language mode, and you want to make sure that you've got a really good sense of how your code is supposed to work.

The hardest part in solving your strict concurrency warnings is that making the compiler happy sometimes just isn't enough. You'll frequently want to make sure that you actually reason about the intent of your code rather than just making the compiler happy.

Consider the following code example:

func loadPages() {
  for page in 0..<10 {
    loadPage(page) { result in 
      // use result
    }
  }
}

We're iterating over a list of numbers and we're making a bunch of network calls. These network calls happen concurrently and our function doesn't wait for them all to complete. Now, the quickest way to migrate this over to Swift concurrency might be to write an async function and a for loop that looks like this:

func loadPages() async throws {
  for page in 0..<10 {
    let result = try await loadPage(page)
    // use result
  }
}

The meaning of this code has now changed entirely. We're making network calls one by one and the function doesn't return until every call is complete. If we do want to introduce Swift concurrency here and keep the same semantics we would have to create unstructured tasks for every single network call or we could use a task group and kick off all our network calls in parallel that way.

Using a task group would change the way this function works, because the function would have to wait for the task group to complete rather than just letting unstructured tasks run. In this refactor, it’s crucial to understand what structured concurrency is and when it makes sense to create unstructured tasks.

You're having to think about what the intent of the code is before you migrate and then also how and if you want to change that during your migration. If you want to keep everything the same, it's often not enough to keep the compiler happy.

While teaching Teams about Swift Concurrency, I found it really important to know exactly which tools you have out there and to think about how you should be reasoning about your code.

Once you've turned on Swift Concurrency checks, it's time to make sure that your entire team knows what to do.

Ensuring your team has all the knowledge they need

I've seen several companies attempt migrations to SwiftUI, Swift Data, and Swift Concurrency. They often take approaches where a small team does all the legwork in terms of exploring and learning these technologies before the rest of the team is requested to learn them too and to adopt them. However, this often means that there's a small team inside of the company that you could consider to be experts. They'll have had access to resources, they'll have had time to train, and once they come up with the general big picture of how things should be done, the rest of the team kind of has to learn on the job. Sometimes this works well, but often this breaks down because the rest of the team simply needs to fully understand what they're dealing with before they can effectively learn.

So I always recommend if you want to migrate over to Swift Concurrency have your team enroll in one of my workshops or use my books or my course or find any other resource that will teach the team everything they need to know. It's really not trivial to pick up Swift Concurrency, especially not if you want to go into strict concurrency mode. Writing async-await functions is relatively easy, but understanding what happens is really what you need if you're planning to migrate and go all-in on concurrency.

Once you've decided that you are going to go for Swift 6 and did you want to level up your team's concurrency skills make sure you actually give everybody on the team a chance to properly learn!

Migrating from the outside in

Once you've started refactoring your packages and it's time to start working on your app target I found that it really makes sense to migrate from the outside in. You could also work from the inside out and in the end, it really depends on where you want to start. That said, I often like to start in the view layer once all the back-end stuff is done because it helps me determine at which point in the app I want to leave the main actor (or when yo apply a main actor annotation to stay on the main actor).

For example, if you’re using MVVM and you have a view model that holds a bunch of functions, where should these functions run?

This is where the work that you did up front comes into play because you already know that in the old way of doing things the view model would run its functions on the main thread. I would highly recommend that you do not change this. If your view model used to run on the main thread which is pretty much standard, keep it that way.

You'll want to apply a main actor annotation to your view model class.

This is not a bad thing by any means, it's not a hack either. It's a way for you to ensure that you're not switching isolation contexts all the time. You really don't need a ton of concurrency in your app code.

Apple is even considering introducing some language mode for Swift where everything is going to be on the main actor by default.

So for you to default your view models and maybe some other objects in your code base to the main actor simply makes a lot of sense. Once you start migrating like this you'll figure out that you really didn't need that much concurrency which you already should know because that's what you figured out early on into process.

This is also where you start to encounter warnings that are related to sendability and isolation contexts. Once you start to see these warnings and errors, you decide that the model should or shouldn't be sendable depending on whether the switch of isolation context that’s causing the warning is expected.

You can solve sendability problems with actors. That said, making things actors is usually not what you're looking for especially when it's related to models.

However, when you’re dealing with a reference type that has mutable state, that's where you might introduce actors. It’s all about figuring out whether you were expecting to use that type in multiple isolation contexts.

Having to deeply reason about every error and warning can sometimes feel tedious because it really slows you down. You can easily make something sendable, you could easily make something an actor, and it wouldn't impact your code that much. But you are introducing a lot of complexity into your codebase when you're introducing isolation contexts and when you're introducing concurrency.

So again, you really want to make sure that you limit the amount of concurrency in your app. You typically don't need a lot of concurrency inside an application. I can't stress this enough.

Pitfalls, caveats, and dangers

Migrating to Swift 6 definitely comes with its dangers and uncertainties. If you're migrating everything all at once, you're going to be embarking on a huge refactor that will involve touching almost every single object in your code. If you introduce actors where they really shouldn't belong, you suddenly have everything in your code becoming concurrent because interacting with actors is an asynchronous proces.

If you didn't follow the steps in this blog post, you're probably going to have asynchronous functions all over the place, and they might be members of classes or your view or anything else. Some of your async functions are going to be isolated to the main actor, but most of them will be non-isolated by default, which means that they can run anywhere. This also means that if you pass models or objects from your view to your few model to some other place that you're skipping isolation contexts all the time. Sometimes this is completely fine, and the compiler will figure out that things are actually safe, but in a lot of cases, the compiler is going to complain about this, and you will be very frustrated about this because you have no idea what's wrong.

There's also the matter of interacting with Apple's code. Not all of Apple's code is necessarily Swift 6 compatible or Swift 6 friendly. So you might find yourself having to write workarounds for interacting with things like a CLLocationManagerDelegate or other objects that come from Apple's frameworks. Sometimes it's trivial to know what to do once you fully understand how isolation works, but a lot of the times you're going to be left guessing about what makes the most sense.

This is simply unavoidable, and we need Apple to work on their code and their annotations to make sure that we can adopt Swift 6 with full confidence.

At the same time, Apple is looking at Swift as a language and figuring out that Swift 6 is really not in the place where they want it to be for general adoption.

If you're adopting Swift 6 right now, there are some things that might change down the line. You have to be willing to deal with that. If you're not willing to deal with that, I would recommend that you go for strict concurrency and don't go all-in on Swift 6 because things might change down the line and you don't want to be doing a ton of work that turns out to be obsolete. A couple versions of Swift down the line, and we're probably talking months, not years, before this happens.

In Summary

Overall, I think adopting Swift 6 is a huge undertaking for most teams. If you haven't started already and you're about to start now, I would urge you to take it slow - take it easy and make sure that you understand what you're doing as much as possible every step of the way.

Swift concurrency is pretty complicated, and Apple is still actively working on improving and changing it because they're still learning about things that are causing problems for people all the time. So for that reason, I'm not even sure that migrating to Swift 6 should be one of your primary goals at this point in time.

Understanding everything around Swift 6 I think is extremely useful because it does help you to write better and safer code. However, I do believe that sticking with the Swift 5 language mode and going for strict concurrency is probably your safest bet because it allows you to write code that may not be fully Swift 6 compliant but works completely fine (at least you can still compile your project even if you have a whole bunch of warnings).

I would love to know your thoughts and progress on migrating to Swift 6. In my workshops I always hear really cool stories about companies that are working on their migration and so if you have stories about your migration and your journey with Swift 6, I would love to hear that.

Subscribe to my newsletter