How to Read and Write a String to a Text File in Swift

How to Read and Write a String to a Text File in Swift

Written by

in

Quick answer: Use FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) to get a writable folder, then call try text.write(to: fileURL, atomically: true, encoding: .utf8) and try String(contentsOf: fileURL, encoding: .utf8). Files shipped inside the app must be read through Bundle.main, which is read-only.

Why this happens

The snippet from the Swift book is not a file I/O example at all. It demonstrates @lazy (now lazy) stored properties: DataImporter is only created the first time manager.importer is touched. Nothing in it opens, reads or writes a file, which is why copying it gets you nowhere.

The second thing that trips people up is location. An app runs inside a sandbox, and the folder your code is compiled into — the bundle — is read-only on a real device. Writing to Bundle.main.bundlePath fails silently or throws. You need a writable directory, and the Documents directory is the standard choice for user-visible files.

The third issue is age. The old APIs (writeToFile(_:atomically:encoding:error:), NSString.stringWithContentsOfFile) no longer exist. Modern Swift uses URL plus throwing methods on String.

How to fix it

1. Write and read in the Documents directory

This is the direct replacement for the book sample. It works in a playground on macOS and in an iOS app.

import Foundation

let fileName = "data.txt"
let text = "Hello World in Swift Language."

let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fileURL = documents.appendingPathComponent(fileName)

// Write
do {
    try text.write(to: fileURL, atomically: true, encoding: .utf8)
} catch {
    print("Write failed: \(error)")
}

// Read back
do {
    let loaded = try String(contentsOf: fileURL, encoding: .utf8)
    print(loaded)
} catch {
    print("Read failed: \(error)")
}

Two details matter here. atomically: true writes to a temporary file first and then swaps it in, so a crash mid-write cannot leave you with a half-written file. And encoding: .utf8 is explicit — never rely on the encoding-less overloads, which guess and can misread non-ASCII text.

If you want the file inside a subfolder, create the folder first, because write(to:) does not create intermediate directories:

try FileManager.default.createDirectory(at: folderURL,
                                        withIntermediateDirectories: true)

2. Read a file that ships inside the app

If data.txt is part of your target (drag it into the project navigator and tick Copy items if needed and your app target under Add to targets), read it from the bundle. Prefer the URL API over path(forResource:) so you never force-unwrap a path string:

import Foundation

if let url = Bundle.main.url(forResource: "data", withExtension: "txt") {
    do {
        let contents = try String(contentsOf: url, encoding: .utf8)
        print(contents)
    } catch {
        print("Could not read bundled file: \(error)")
    }
} else {
    print("data.txt is not in the bundle")
}

If this branch prints “not in the bundle”, the file is missing from Copy Bundle Resources in Build Phases, or it was added to the project but not to the app target.

3. Do not swallow the error

The accepted answer’s catch { /* error handling here */ } is fine as a placeholder, but in real code an empty catch hides the two failures you will actually hit: the directory does not exist, or the file is not there yet. Log the error, or let it propagate:

func loadText(at url: URL) throws -> String {
    try String(contentsOf: url, encoding: .utf8)
}

Avoid try? for reads you care about — it turns every failure into nil and you lose the reason.

4. Copy a bundled file to Documents before editing it

A common pattern is shipping a default file and letting the user modify it. Since the bundle is read-only, copy it once on first launch:

let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let destination = documents.appendingPathComponent("data.txt")

if !FileManager.default.fileExists(atPath: destination.path),
   let source = Bundle.main.url(forResource: "data", withExtension: "txt") {
    try FileManager.default.copyItem(at: source, to: destination)
}

After that, all reads and writes go to destination.

How to prevent it

  • Never write into Bundle.main.bundlePath or Bundle.main.resourcePath — the bundle is read-only on device.
  • Build paths with URL.appendingPathComponent(_:), not string concatenation with "/".
  • Always pass encoding: .utf8 explicitly on both read and write.
  • Use atomically: true when overwriting a file the app may read again.
  • Handle the throws from write(to:) and String(contentsOf:); an empty catch will cost you an hour of debugging later.
  • For files shared with an extension or another app, use FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:) instead of the Documents directory.

FAQ

Where does the file actually end up on the device?

Inside the app’s sandbox, under Documents. On the Simulator you can open it from Finder via the app container. On a device it is only reachable through Xcode’s Devices window or the Files app if you enable file sharing in Info.plist.

Why does Xcode warn that String(contentsOfFile:) is deprecated?

The overload without an encoding guesses the encoding from the bytes, which is unreliable. Use String(contentsOf: url, encoding: .utf8) instead — it is the supported form and it throws a real error when the file is missing.

Can I read a file from the bundle and then write back to the same path?

No. The bundle is signed and read-only at runtime. Copy the file to the Documents directory on first launch and read and write that copy from then on.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.