Skip to content
Swift Error Handling: Try, Throw & Do-Catch Explained

17 Minutes

Swift Error Handling: Try, Throw & Do-Catch Explained

Fix Bugs Faster! Log Collection Made Easy

Get started

Introduction to Swift error handling

As developers, we know errors are unavoidable. Invalid inputs, failed network calls, or missing files can break even the most polished apps. Thankfully Swift provides a robust error handling system (also called exception handling) to help us detect, propagate, and recover from these issues gracefully.

In this guide, we’ll explore:

  • Common causes and error types in Swift
  • Defining and throwing errors with enums and the throw keyword
  • Handling errors using try, try?, try!, and do-catch
  • Best practices for writing clear, maintainable error handling code
  • Advanced techniques like typed throws, defer, async handling, and protocols
  • A practical example: building a simple network client.

Common causes and error types in Swift

Errors in Swift can come from lots of different sources. Some need fixes in our code, others can be handled at runtime with retries, defaults, or better input checks. Here are some typical examples:

CauseErrorType & Where to Act
User entered invalid data (divide by zero, bad format)Invalid inputRuntime / logic error → validate input before using it
File can’t be found or read (missing, no permission)File errorI/O error → check file paths, permissions, or add defaults
Network fails (timeout, offline, bad response)Network errorConnectivity error → retry requests or show user-friendly messages
Code mistake (unexpected value, nil where needed)Logic errorProgram bug → fix in code with guards, tests, or better design
Custom rules (e.g. payment declined, stock empty)Custom errorApp-defined → decide app behavior (retry, show alert, etc.)

Now, let’s look at how to fix these errors – starting with how to categorize them.

Defining custom errors with enums in Swift

In Swift, we usually create our own error types to describe the problems our code might run into. To do this, we define an enum that conforms to the Error protocol. Each case in the enum represents a specific error situation. This makes errors easy to identify, handle, and extend later as your app grows. Learn more in our guide to Swift enums.

enum FileError: Error {
    case fileNotFound
    case permissionDenied
    case writeFailed
}

Instead of throwing a generic error, we can throw one of these cases. This gives us clear, meaningful errors (like fileNotFound), which can be caught and handled in different ways depending on the situation.

Throwing errors in Swift with the throw keyword

One of the most important considerations when handling errors is the ability to throw them, which essentially means the way we let our methods know an error has occurred.

In Swift we can do this by using the throws keyword to indicate that a function can throw an error, and the throw statement inside the function to actually throw the error.

As an example, we’ve set up a simple method to open a file and write to it, then throw any errors as appropriate:

enum FileError: Error { 
	case fileNotFound 
	case permissionDenied 
} 

func writeToFile(named filename: String) throws { 
	// Read file 
	if fileNotFound { 
		throw FileError.fileNotFound 
	} 
	
	// Write to file
	if permissionsDenied {
		throw FileError.permissionDenied
	}
}

This is obviously a very simple example, but we can see how errors are thrown and how we throw a different error based on the error condition.

How to handle errors in Swift

Now, let’s get into the stuff you came to read. We’re about to really get into the weeds of Swift-error handling… let’s do it!

The differences between try, try? and try!

Swift gives us several ways to deal with errors when calling a throwing function. The try family (try, try?, and try!) decides what happens if something goes wrong: do we pass the error up, turn it into nil, or crash the program if we’re sure it can’t fail?

On top of the try resources, Swift provides the do-catch statement, which lets us wrap risky code in a safe block and react if an error is thrown. Together, these tools form the core of error handling in Swift.

KeywordBehaviorWhen to Use
tryPropagates the error to be handled elsewhere with do-catchStandard choice when you want to handle or pass on errors
try?Converts errors into nil (optional value)When failure isn’t critical and you’re fine with a default or no value
try!Forces execution, crashes if an error occursOnly when you’re absolutely certain no error can happen (e.g. bundled resource)

Using try in Swift with throwing functions

When a function is marked with throws, we must call it with the try keyword. This marks the call as one that can fail and ensures the error will either be handled later or passed up to the caller. On its own, try doesn’t stop or fix anything — it simply warns that the function may throw.

func myMethod() throws {
    try writeToFile(named: "myFileName.txt")
    // Success code here runs only if no error is thrown
}

Here, myMethod also uses throws, which means it doesn’t catch the error itself. To actually handle errors, we’ll need a do-catch block — that’s the next step.

Handling errors in Swift using do-catch

Even when we’ve marked a function call with try, we still need a way to respond if it fails. The most common approach is the do-catch statement, which runs our code inside a do block and reacts with catch if an error is thrown.

func myMethod() {
    //...
    do {
        try writeToFile(named: "myFileName.txt")
        // Success code, after writing to the file
    } catch let error {
        print(error)
    }
}

Here, any error from writeToFile is caught and handled safely. We can also add multiple catch clauses to handle different error types — we’ll explore this later with real networking examples.

All good so far? Cool. Now, let’s take the next step and look at how Swift propagates errors.

Converting errors to optionals with try?

try? is useful when you don’t need to handle an error in detail. Instead of throwing, it converts any failure into nil (no value). This way, if something goes wrong, your program continues running without crashing.

if let message = try? saveMessage("Hello, world!") {
    print("Message saved:", message)
} else {
    print("Message could not be saved.")
}

If the function succeeds, message contains the result and the success branch runs. If it fails, message becomes nil and the else branch handles the failure gracefully.

Disabling error propagation with try!

In Swift, the try! keyword is used when you know for sure a throwing function will not fail. Unlike try?, which safely returns nil if an error occurs, try! disables error propagation and will crash the app if an error is actually thrown. This makes it risky, but useful in cases where failure is impossible (e.g., loading a bundled file you know exists).

Always use try! with caution, and only when you’re certain the call cannot fail.

try! writeToFile(named: "myFileName.txt")

Best practices for error handling in Swift

Ok, so we’ve covered the basic tools for error handling in Swift, and shown how powerful the toolkit is. But writing reliable code means more than just using try or do-catch. Following best practices helps keep our code maintainable, predictable, and user-friendly.

As well as mastering the resources mentioned above, we’d advise you to consider some key points:

  • Propagate errors at the right level instead of handling them everywhere.
  • Write clear and meaningful messages to help debugging and users.
  • Use try? only for simple, non-critical failures.
  • Add guard statements for clean, early exits when something goes wrong.
  • Test and plan for failure cases so you know your app won’t break unexpectedly.

Propagate errors at the right level

In Swift, we don’t need to handle every error where it occurs. Instead, let errors bubble up until they reach the part of our code that actually knows what to do. This keeps our lower-level methods clean and centralizes decision-making in one place.

enum FileError: Error {
    case fileNotFound
    case permissionDenied
    case writeFailed
}

func writeToFile() throws {
    // simulate different errors
    throw FileError.fileNotFound
}

func saveDocument() throws {
    try writeToFile()
}

func performSave() {
    do {
        try saveDocument()
    } catch FileError.fileNotFound {
        print("Error: The file could not be found.")
    } catch FileError.permissionDenied {
        print("Error: You don’t have permission to save here.")
    } catch FileError.writeFailed {
        print("Error: Something went wrong while writing.")
    } catch {
        print("Unexpected error:", error)
    }
}

Here, writeToFile throws, saveDocument just passes the error up, and all handling happens in performSave. This way we keep the handling logic in one place and avoid repeating it in every function.

Write clear and meaningful error messages

Errors should explain what went wrong, not leave people guessing. Compare these two approaches:

Bad example – vague and unhelpful:

catch {
    print("Error occurred")
}

Good example – clear and actionable:

catch FileError.fileNotFound {
    print("Error: The file could not be found at the given path.")
} catch FileError.permissionDenied {
    print("Error: You don’t have permission to save this file.")
} catch {
    print("Unexpected error:", error)
}

The second example makes debugging easier and gives users messages they can actually understand and act on.

When to use try? for simple, non-critical failures

try? works well when errors aren’t critical and you can continue without them. Instead of stopping the program, failures become nil and can be ignored or replaced with a default.

Good Situations (use try?)Bad Situations (avoid try?)
Loading a cached file that may not existProcessing payments or financial transactions
Playing a sound effect that might be missingSaving critical user data (documents, backups)
Parsing optional metadata from a network responseHandling authentication (e.g. login, API keys)
Fetching non-essential settings or preferencesWriting to a database or permanent storage
Displaying a preview image that might fail to loadPerforming file operations where data loss is possible

With try?, use it only when ignoring the error is safe and won’t harm the user experience.

Using guard for early error handling

Another effective way to handle errors in Swift is with guard. A guard allows us to check for problems early, and if something goes wrong, exit the scope immediately. This keeps our code clean and avoids deep nesting. To use it, we can slightly adjust our throwing function to return a Bool:

func writeToFile(named filename: String) throws -> Bool {
    // Read file
    if fileNotFound {
        throw FileError.fileNotFound
    }
    
    // Write to file
    if permissionsDenied {
        throw FileError.permissionDenied
    }
} 

Now we can guard against failure like this:

guard try writeToFile(named: "myFileName.txt") else {
    // Here we would need to exit scope, usually with a return
}

// on a successful file write, the code here would execute

Test and plan for failure cases

We should always test what happens when things fail, not just when they work. By simulating common errors, we make sure our app stays stable and gives users clear feedback.

FailureWhat to testHow to handle
Missing fileTry to open a file that doesn’t existShow a message or create a new file
No permissionWrite to a protected folderStop and tell the user
Bad dataParse invalid JSONFail safely and use a default value
No internetMake a request offlineRetry later or show offline mode
Wrong inputDivide by zeroCatch the error and prevent a crash

Swift error handling example: simple network client

The best way to understand is to put the theory into practice with a working example and we’re going to setup a simple network client to demonstrate. Ready? Let’s go…

Initial setup

Our RequestClient will initially only have an init that accepts a URLSession, and if none is provided it uses the shared default as shown below:

final class RequestClient {
	
	private let session: URLSession
	
	init(_ session: URLSession = URLSession.shared) {
		self.session = session
	}
}

Making requests

Now let’s add a method to allow us to make requests. We’ll accept the method type, defaulting to GET, the URL string, a body and headers. It will have a completion block that will use the previously seen Result type, to either return the data from our request or an error.

Our method is as follows:


func perform(method: String = "GET",
				 onURL urlString: String,
				 withBody body: [String: AnyObject] = [:],
				 withHeaders headers: [String: String] = [:],
				 completion: @escaping (Result<Data, Error>) -> Void) {
		
		guard let url = URL(string: urlString) else {
			completion(.failure()) // 1
			return
		}
		
		var request = URLRequest(url: url)
		request.httpMethod = method

		headers.forEach { (key: String, value: String) in
			request.setValue(value, forHTTPHeaderField: key)
		}
		
		do {
			let jsonifiedBody = try JSONSerialization.data(withJSONObject: body)
			request.httpBody = jsonifiedBody

		} catch let error {
			completion(.failure()) // 2
		}
		

		URLSession.shared.dataTask(with: request) { data, response, error in
			if error != nil {
				completion(.failure()) // 3
				return
			}
			
			if let response = response as? HTTPURLResponse, let data = data {
				switch response.statusCode {
				case 200...299:
					completion(.success(data))
				case 400...499:
					completion(.failure()) // 4
				case 500...599:
					completion(.failure()) // 4
				default: 
					completion(.failure()) // 4
			} else {
					completion(.failure()) // 4
			}
		}
	}

There are plenty of points at which it can fail and tell the method caller that an error occurred, these are:

  1. When trying to create a URL from the received String
  2. When turning the body into a jsonified body to be sent with the request
  3. When an error occurs with the request itself
  4. When any status code received is not in the acceptable range we’re expecting

💡 Learn more about status codes and their meanings here.

Now, as we already know all the possible errors our method may need to identify, we can now create our Error enum. For cases two and three, an error is already being thrown locally so our Error should take that as an associated value.

💡 Learn more about enums and associated values in our article here.

Similarly, since we already have error codes in scenario four, we can use those codes as associated values, as shown here:

enum RequestError: Error {
	case invalidURL
	case invalidBodyFormat(error: Error)
	case requestError(error: Error)
	case invalidRequest(code: Int)
	case serverError(code: Int)
	case unknownError(code: Int)
	case wrongResponseFormat
}

Now we have all our possible errors, we can see how our RequestClient looks when everything is in place:

final class RequestClient {
	
	private let session: URLSession
	
	init(_ session: URLSession = URLSession.shared) {
		self.session = session
	}
	
	
	func perform(method: String = "GET",
				 onURL urlString: String,
				 withBody body: [String: AnyObject] = [:],
				 withHeaders headers: [String: String] = [:],
				 completion: @escaping (Result<Data, Error>) -> Void) {
		
		guard let url = URL(string: urlString) else {
			completion(.failure(RequestError.invalidURL)) 
			return
		}
		
		var request = URLRequest(url: url)
		request.httpMethod = method

		headers.forEach { (key: String, value: String) in
			request.setValue(value, forHTTPHeaderField: key)
		}
		
		do {
			let jsonifiedBody = try JSONSerialization.data(withJSONObject: body)
			request.httpBody = jsonifiedBody

		} catch let error {
			completion(.failure(RequestError.invalidBodyFormat(error: error)) 
		}
		

		URLSession.shared.dataTask(with: request) { data, response, error in
			if error != nil {
				completion(.failure(RequestError.requestError(error: error!)))
				return
			}
			
			if let response = response as? HTTPURLResponse, let data = data {
				switch response.statusCode {
				case 200...299:
					completion(.success(data))
				case 400...499:
					completion(.failure(RequestError.invalidRequest(code: response.statusCode))) 
				case 500...599:
					completion(.failure(RequestError.serverError(code: response.statusCode))
				default: 
					completion(.failure(RequestError.unknownError(code: response.statusCode)) 
			} else {
					completion(.failure(RequestError.wrongResponseFormat)) 
			}
		}
	}
}

While we’re using it as an example, it is a legitimate request maker class that you could use, so long as you make sure to assign the errors with names errors that make sense in your own scope.

And that’s it – our client is now ready to use and will run like this:

let client = RequestClient()
client.perform(method: "GET",
               onURL: "<https://httpbin.org/get>") { [weak self] result in
    switch result {
    case .success(let data):
        // handle our data
    case .failure(let error):
        // handle our error here. 
        // Show an alert to the user if appropriate. 
        // Log the error into our favourite logging framework, 
        // like BugFender.
    }
}

Advanced techniques for error handling in Swift

Beyond the basics, Swift gives us powerful features to handle errors more precisely and write cleaner code. These techniques are especially useful in larger apps where stability matters most:

  • Typed throws – restrict functions to throw only certain error types
  • defer keyword – ensure cleanup code always runs before exiting a scope
  • Async error handling – use Result and DispatchGroup to manage failures in concurrent code
  • Optional chaining (?.) and nil-coalescing (??) – simplify error-prone code with safe defaults
  • Protocols with error handling – enforce consistent strategies across multiple types

Limiting errors with typed throws

By default, any function marked with throws can throw any error type, which sometimes makes it harder to know what to handle. With typed throws, we restrict a function to a specific error type, so the caller knows exactly what failures to expect.

enum MathError: Error {
    case divideByZero
}

func divide(_ a: Int, _ b: Int) throws(MathError) -> Int {
    guard b != 0 else { throw .divideByZero }
    return a / b
}

Being explicit is most useful when:

  • A function has predictable, limited failure modes (like division by zero).
  • You want stronger compile-time checks and avoid catching unrelated errors.

Async error handling with Result type

The Result type provides a clean way to manage both success and failure in async operations. It’s particularly useful in network calls, where outcomes are often unpredictable.

For example, here’s a simple parseDataToUser function that pretends to parse a Data object and either returns a User or an error:

func parseDataToUser(dataObject: Data, result: (Result<User, Error>) -> Void) {
    //here it would try to parse said data
		if anyError {
			result(.failure(myError))
		}    

		result(.success(myParsedUser))
}

Now we could use this method with Result type support like this:

parseDataToUser(dataObject: ourDataObject) { result in
    switch result {
    case .success(let user): break
    case .failure(let error): break
    }
}

This approach makes both paths explicit. By switching on .success or .failure, we gain clarity and can return meaningful messages instead of leaving errors hidden.

Coordinating async tasks with DispatchGroup

Sometimes we need to run multiple async tasks, like making several network requests, and only proceed once all have finished. Swift’s DispatchGroup helps us coordinate this. For example, imagine fetching user data and messages at the same time, then updating the UI only when both are ready:

let group = DispatchGroup()

group.enter()
fetchUserData { group.leave() }

group.enter()
fetchMessages { group.leave() }

group.notify(queue: .main) {
    print("User data and messages loaded")
}

This way we avoid partial results and ensure errors or updates are handled only after all tasks complete.

Optional chaining with ?.

Optional chaining lets us safely access properties or methods on optionals without crashing. Instead of forcing unwrapping, Swift returns nil if the value doesn’t exist.

Example: user?.profile?.email

  • Returns the email if all values exist
  • Returns nil if any step in the chain fails

This allows us to:

  • Avoid runtime crashes by skipping forced unwrapping
  • Cut down repetitive if let or guard checks
  • Handle deeply nested optionals more easily
  • Keep code shorter, safer, and easier to read

Nil-coalescing with ??

The nil-coalescing operator lets us provide a default value when an optional is nil. For example, let name = user.name ?? "Guest" assigns "Guest" if no name exists.

This allows us to:

  • Guarantee a fallback value when something is missing
  • Simplify error handling for optional values
  • osImprove user experience with friendly defaults (like “Guest”)
  • Keep code concise and readable without extra checks

To sum up Swift error handling

Good error handling isn’t just about avoiding crashes, it’s about keeping apps stable, users happy, and debugging time under control. In this guide we’ve seen how to define, throw, and catch errors, when to use try, try?, or try!, and how to centralize handling so our code stays clean.

We also built a simple RequestClient to show how these techniques work in practice.

And here’s the real win: combine these approaches with proper monitoring. With tools like Bugfender for iOS, you can capture crash reports, get notified in real time, and fix issues before users even complain. That’s how you move from firefighting bugs to building rock-solid apps.

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Flávio Silvério

Interested in how things work since I was little, I love to try and figure out simple ways to solve complex issues. On my free time you'll be able to find me with the family strolling around a park, or trying to learn something new. You can contact him on Linkedin or GitHub

Join thousands of developers
and start fixing bugs faster than ever.