Do you have to deal with Base64 format? Then this site is perfect for you! Use our super handy online tool to encode or decode your data.

Base64 Encoding in Swift: A Complete Guide

You have something that needs to travel, and the road is text-only: a JSON API that refuses raw bytes, an e-mail channel that remembers its 7-bit origins, a URL that chokes on anything it cannot name, a config file that will only accept the plainest of characters. Welcome to the packing side of base64, where Swift turns your bytes into a friendly wall of letters with one method call, a surcharge of roughly one extra character for every three, and a few wrapping options that exist because two different decades had opinions about line lengths.

The home page above already explains the format in detail (64 printable characters, four of them per three input bytes, up to two = characters of padding on the final group), so the format lecture is over before it starts. Two facts to carry into this article: base64 is packing, not locking, and the packing expands your data by about 33 percent, which matters every time you are near a size limit. In Swift, the whole job runs through one type, Data, and one total method, base64EncodedString(options:). The only real skill required is knowing what happens in the two steps around that method, because the method itself never fails. It is the steps that do.

One Method, Zero Excuses

Everything base64-related in Swift lives on Data from the Foundation framework, and it has lived there since the language's first releases (Apple lists the method from iOS 8.0, macOS 10.10, tvOS 9.0, watchOS 2.0 and visionOS 1.0). The pipeline is always the same three steps: get your content into a Data, call the method, ship the string.

import Foundation

let note = "Pack it, wrap it, ship it."
let packed = Data(note.utf8).base64EncodedString()
print(packed) // UGFjayBpdCwgd3JhcCBpdCwgc2hpcCBpdC4=

Two details in those three lines deserve a closer look. First, Data(note.utf8) is the quiet step: the utf8 spelled view can represent every Unicode scalar by definition, so it never fails, which is why it is the default in most examples. The failable cousin, note.data(using:), can and does answer nil for some encodings, and that whole "which bytes" decision gets its own section below, because it is the first place your data can go missing. Second, the method itself is total: it always answers, it has no error case, and the only question it asks you is which line wrapping you want. There is also a sibling, base64EncodedData(options:), which returns the packed result as Data of ASCII bytes instead of a string, for pipelines where the next stop is a binary API rather than a text field.

And because half of you arrived via "my Swift app needs a base64 dependency": there is nothing to install. Base64 is part of Foundation, Foundation is part of the toolchain, and the toolchain arrives the same way on every platform. On macOS it is Xcode or the command line tools; on Linux and Windows it is the installer from swift.org, where the current stable line as of this writing is 6.3.x and the Swiftly version manager is the recommended front door; and official Docker images cover the container crowd. Your Package.swift stays empty, and it should.

The First Real Decision: Which Bytes?

Before a single base64 character is produced, you have already made the decision that matters most, because base64 packs bytes, and a string is only a string until you choose its byte form. UTF-8 is the sane default and the right answer for almost everything, but the moment your data comes from a legacy system, a binary protocol, or a corner of Unicode, the choice stops being invisible:

import Foundation

let phrase = "héllo"
print(phrase.data(using: .utf8)?.count ?? -1)              // 6
print(phrase.data(using: .ascii) == nil)                   // true
print(phrase.data(using: .utf16)?.count ?? -1)             // 12
print(phrase.data(using: .utf16LittleEndian)?.count ?? -1) // 10
print(phrase.data(using: .utf8)!.base64EncodedString())
// aMOpbGxv
print(phrase.data(using: .utf16LittleEndian)!.base64EncodedString())
// aADpAGwAbABvAA==
Conversion Bytes for "héllo" What the base64 carries
.utf8 6 aMOpbGxv, the spelling modern APIs expect
.ascii fails with nil the accented character sits above 0x7F and ASCII refuses it
.utf16 12 twice the size of UTF-8, plus a two-byte byte-order mark riding along at the front
.utf16LittleEndian 10 the same word without the BOM tag, still twice the UTF-8 weight

Three lessons hide in that output. The data(using:) form is failable and .ascii is a happy candidate to fail, so force-unwrapping it is how a perfectly good phrase becomes a crashed app. The plain .utf16 conversion prepends a two-byte byte-order mark (FF FE on a little-endian machine), and that BOM travels into your packed output and confuses any decoder that did not expect it. And the size math is unforgiving: a careless charset choice costs you the base64 surcharge on double the data, so the question is never "will this encode?" but "what will the other end expect to find when it unpacks?" The golden rule: both ends of the trip must agree on the byte form before the base64 starts, because the decoder has no way to guess what you chose and it will not ask.

Wrapping: Two Habits, One Parameter

The method's options are all about line breaking, and all of them exist because two twentieth-century formats could not agree on how long a line of letters should be. MIME, the 1996 e-mail standard, wraps base64 at 76 characters with CRLF line endings. PEM, the privacy-encrypted-mail lineage from 1987, wraps at 64 characters, and that is the shape you find inside certificates and keys, the -----BEGIN CERTIFICATE----- blocks your servers keep in a config directory.

import Foundation

let certBytes = Data((0..<300).map { UInt8($0 % 256) })
let raw = certBytes.base64EncodedString()
let pemStyle = certBytes.base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed])
let mimeStyle = certBytes.base64EncodedString(options: [.lineLength76Characters,
  .endLineWithCarriageReturn, .endLineWithLineFeed])
print(raw.count)                                        // 400 characters on one line
print(pemStyle.components(separatedBy: "\n").count)    // 7 lines of at most 64
print(mimeStyle.components(separatedBy: "\r\n").count) // 6 lines of at most 76
Option Job Watch out
.lineLength64Characters cut a line after 64 characters, the PEM habit the line ending is CRLF unless you say otherwise
.lineLength76Characters cut a line after 76 characters, the MIME habit same CRLF default
.endLineWithCarriageReturn include a carriage return in the line ending on its own this is CR-only, old-Mac style, and rarely what you want
.endLineWithLineFeed include a line feed in the line ending pass both options when you mean CRLF

Now the default that surprises people: ask for any .lineLength option without choosing a line ending, and the line ending you receive is CRLF, the full carriage-return-plus-line-feed pair. The method has a house style, and its house style is 1996. Want LF-only? Pay for it explicitly with .endLineWithLineFeed and nothing else. One more house rule for the record: the final line never gets a trailing line ending. A wrapped result ends with its last data character or its = pads, no matter which options you chose, so you can concatenate and paste without an orphan blank line at the end. And with no options at all, the output is a single unbroken line, which is the right shape for JSON bodies, URLs, and API payloads: the work a modern Swift app actually does most of the time.

Base64url: A String That Can Travel

The standard alphabet is a fine citizen of JSON and a terrible citizen of a URL. In a query string, + is read as a space by form parsing, / is a path separator, and = separates keys from values, which is why percent-encoding the standard alphabet makes it longer and uglier instead of shorter. Section 5 of RFC 4648 exists to fix exactly that: the "URL and Filename Safe Alphabet", where + becomes -, / becomes _, and the = padding is usually dropped because a pad in a URL typically becomes %3D, defeating the purpose. The RFC adds a warning worth framing: this encoding "should not be regarded as the same as the base64 encoding". YouTube video ids, JWTs, and most modern API identifiers speak it, so expect to use it.

import Foundation

extension Data {
  var base64URLEncoded: String {
    base64EncodedString()
      .replacingOccurrences(of: "+", with: "-")
      .replacingOccurrences(of: "/", with: "_")
      .replacingOccurrences(of: "=", with: "")
  }
}

let tricky = Data("The + / and = trio goes home.".utf8)
print(tricky.base64EncodedString())
// VGhlICsgLyBhbmQgPSB0cmlvIGdvZXMgaG9tZS4=
print(tricky.base64URLEncoded)
// VGhlICsgLyBhbmQgPSB0cmlvIGdvZXMgaG9tZS4

Look at that output closely: this particular payload happened not to produce a + or a /, so the two spellings differ only by the dropped padding. Change one byte and they will diverge in the alphabet, which is the whole point. Two rules of engagement. Pick the dialect once, at the boundary where your data meets the outside world, and never mix alphabets inside the same document: a standard decoder that receives base64url (or vice versa) will either reject the input or, in lenient modes, delete the foreign characters and hand you wrong bytes. And name your helper honestly, so the next developer knows the string is base64url and not a typo. The same extension will get shorter one day: the newest Apple SDKs (26.4 and up) now include a native .base64URLAlphabet option that does the alphabet swap inside the framework, with a matching .omitPaddingCharacter option, and open-source Foundation carries the same options behind an availability marker for a later toolchain. Until they reach your minimum deployment target, the four-line extension is the portable answer, and it will keep working on every platform by construction.

JSON and APIs: The Base64 You Never Asked For

This one surprises the most people who work with Codable, so it earns its own section: JSONEncoder's default strategy for a Data property is already base64. If a Codable struct has a Data field, the encoder packs it with standard base64 automatically, and JSONDecoder unpacks it automatically on the way back. No option, no configuration, no ceremony.

import Foundation

struct Snapshot: Codable {
  let name: String
  let icon: Data
}

let snap = Snapshot(name: "cat", icon: Data("🐱".utf8))
let json = try JSONEncoder().encode(snap)
print(String(decoding: json, as: UTF8.self))
// the icon crossed the wire as "8J+QsQ=="

The icon property crossed the wire as 8J+QsQ== because that is the house style. There are alternatives, and the two you will actually meet are .custom, which hands you the data and an encoder and lets you decide the representation, and the newer .deferredToData, which defers to the data instance itself. The moment an API wants base64url instead of standard, .custom is where your extension from the previous section plugs in:

import Foundation

extension Data {
  var base64URLEncoded: String {
    base64EncodedString()
      .replacingOccurrences(of: "+", with: "-")
      .replacingOccurrences(of: "/", with: "_")
      .replacingOccurrences(of: "=", with: "")
  }
}

struct Snapshot: Codable {
  let name: String
  let icon: Data
}

let encoder = JSONEncoder()
encoder.dataEncodingStrategy = .custom { data, enc in
  var container = enc.singleValueContainer()
  try container.encode(data.base64URLEncoded)
}
let json = try encoder.encode(Snapshot(name: "cat", icon: Data("🐱".utf8)))
print(String(decoding: json, as: UTF8.self))
// the icon crossed the wire as "8J-QsQ"

One warning that separates a working feature from a production incident: a JSON string may not contain a raw line break. If you wrap a payload with a .lineLength option and interpolate the result into a JSON document without escaping, you have not made a JSON value at all, you have made a syntax error with a base64 accent, and the parser will prove it. Wrapped output belongs in e-mail bodies and certificate files. Everything that lives inside JSON, URLs, or query strings gets the plain unwrapped string.

Data URIs: The Image in a String

The web's favorite trick is embedding a file's bytes directly in a URL: data:{mime};base64,{payload}. Building one in Swift is a read, an encode, and a string concatenation:

import Foundation

let gif = Data(base64Encoded: "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")!
let uri = "data:image/gif;base64," + gif.base64EncodedString()
print(uri.hasPrefix("data:image/gif;base64,R0lGODlh")) // true
print(gif.count) // 42

The example rebuilds the famous 42-byte transparent GIF, the smallest image in the format, into a data URI that a browser will render without a second request. On Apple platforms the reverse direction is a one-liner: the same Data you packed feeds straight into UIImage(data:) or NSImage(data:). The trade-off is size and it compounds: a 100 kilobyte image becomes a string of over 133,000 characters before you even add the data:image/png;base64, prefix. Data URIs shine for icons, avatars, and tiny assets, and they quietly bloat bandwidth for hero photos, so keep them for the small things.

JWTs: Sealing the First Two Parts

The encoding side of a JSON Web Token is two sealings plus a signature, and the sealing is your base64url extension with padding dropped, which is exactly what the format demands. The header and the payload are JSON documents, and both parts get the same treatment:

import Foundation

extension Data {
  var base64URLEncoded: String {
    base64EncodedString()
      .replacingOccurrences(of: "+", with: "-")
      .replacingOccurrences(of: "/", with: "_")
      .replacingOccurrences(of: "=", with: "")
  }
}

func seal(_ text: String) -> String {
  Data(text.utf8).base64URLEncoded
}

let header = seal(#"{"alg":"HS256","typ":"JWT"}"#)
let claims = seal(#"{"sub":"42","role":"editor"}"#)
print("\(header).\(claims).signature-here")
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJlZGl0b3IifQ.signature-here

Two reminders. The third dot-separated part is a cryptographic signature computed over the first two, and it is the only part of the token that provides any guarantee: the header and the claims are plain JSON wearing a trench coat, so secrets never go in them. And note how the padding vanishes in seal(): JWT decoders on the other side (including the one in the sister article) put it back with a modulo top-up, so the two directions of the trip meet on common ground.

HTTP Headers: Basic and the Rest

The old Authorization: Basic header wants a username and a password, joined by a colon, packed with standard base64, because in a header + and / are harmless and the dialect question does not arise:

import Foundation

let credentials = "editor:s3cret"
let header = "Basic " + Data(credentials.utf8).base64EncodedString()
print("Authorization: " + header)
// Authorization: Basic ZWRpdG9yOnMzY3JldA==

Same loud footnote as everywhere: the packing provides zero security by itself, and the header is only as safe as the HTTPS connection carrying it. The modern cousin, Authorization: Bearer, carries a JWT instead, so the sealing recipe from the JWT section is what goes on the wire there. The one place the dialect question does arise in HTTP is the query string: if your API lets an identifier ride in a URL, that identifier should be base64url, or at the very least percent-encoded standard base64, never the raw standard alphabet with its + left to be read as a space.

Email Attachments: The 76-Character Contract

When your app produces an attachment that must survive SMTP's 7-bit origins, the contract is MIME's: base64 wrapped at 76 characters with CRLF line endings, and a Content-Transfer-Encoding: base64 header telling the receiver what to expect. The options section already showed the spelling; here is the complete shape of a wrapped body:

import Foundation

let attachment = Data((0..<400).map { UInt8(65 + $0 % 26) })
let body = attachment.base64EncodedString(options: [.lineLength76Characters,
  .endLineWithCarriageReturn, .endLineWithLineFeed])
let lines = body.components(separatedBy: "\r\n")
print(lines.count)                     // 8 lines
print(lines.map { $0.count }.max() ?? 0) // 76, the longest
print(body.hasSuffix("\r\n"))           // false, the last line stays bare

The size bill for this dialect is the famous one: the 4/3 alphabet tax plus a line break every 76 characters lands near 137 percent of the original, and the old mail-engineering shortcut "multiply the original by 1.37 and add roughly 800 bytes of headers" still works for eyeballing attachment sizes in a mail client. It is folklore with correct arithmetic, and it is the one place in this article where the 33 percent surcharge grows a second decimal.

Config, Environment and Databases: Hiding the Underscore

There is a quiet class of jobs where base64's only virtue is that its output is a small, predictable character set: stashing a binary blob or a structured value in a place that wants plain text. Environment variables that must survive a shell config file, columns in a database that is happier with varchar than blob, an LDAP file with its base64 marker, a QR code that scans letters more reliably than bits. The pattern is the same everywhere: decide the bytes, encode, store the string, decode at the other end.

import Foundation

struct FeatureFlags: Codable {
  var betaToolbar: Bool
  var maxRetries: Int
}

do {
  let flags = FeatureFlags(betaToolbar: true, maxRetries: 5)
  let json = try JSONEncoder().encode(flags)
  let storable = json.base64EncodedString()
  print(storable)
  guard let packed = Data(base64Encoded: storable) else {
    print("decode failed, that is odd")
    exit(1)
  }
  let restored = try JSONDecoder().decode(FeatureFlags.self, from: packed)
  print(restored.betaToolbar, restored.maxRetries)
} catch {
  print(error)
}

Two pitfalls live here. The first is the double wrap: two integration layers that both "helpfully" encode, so the value you store is base64 of base64, and the reader who decodes once gets a wall of letters and thinks the feature is broken. Encode exactly once, at exactly one boundary, and say so in a comment. The second is dialect drift by environment: if the value will ever travel through a URL, a form field, or a shell that mangles + and /, store the base64url spelling instead, because the character set is the whole point of the format.

Files: The .b64 Round Trip

The "turn this file into a .b64 text file" job is a read, a call, and a write:

import Foundation

let source = URL(fileURLWithPath: "photos/cat.png")
let archive = URL(fileURLWithPath: "photos/cat.b64")
let bytes = try Data(contentsOf: source)
try Data(bytes.base64EncodedString().utf8).write(to: archive)

// later, possibly in another process
let packed = try String(contentsOf: archive, encoding: .utf8)
let restored = Data(base64Encoded:
  packed.trimmingCharacters(in: .whitespacesAndNewlines))
if let restored = restored {
  try restored.write(to: URL(fileURLWithPath: "photos/cat-copy.png"))
} else {
  print("the .b64 file was not base64 after all")
}

The trimmingCharacters on the return journey is there because whatever wrote the file may have added a line ending, and the strict decoder treats a trailing newline as a verdict of nil. That round trip comes back byte for byte, which you should verify the first time you ship it. For files large enough to make memory usage interesting, do not encode the whole buffer at once. Base64 has a lovely property that makes streaming exact: every three input bytes produce four independent output characters, so as long as each chunk you encode is a multiple of three bytes, the concatenated output is identical to encoding the whole file in one go. Break the alignment and the output changes, because a chunk boundary splits a three-byte group mid-stream:

import Foundation

func streamEncode(_ input: InputStream, output: OutputStream, lineLength: Int = 76) throws {
  input.open()
  output.open()
  defer { input.close(); output.close() }
  var buffer = [UInt8](repeating: 0, count: 65_536)
  var pending = [UInt8]()
  var line = ""
  var lineCount = 0
  func addText(_ text: String) {
    line += text
    while line.count > lineLength {
      if lineCount > 0 { _ = output.write(Array("\r\n".utf8), maxLength: 2) }
      _ = output.write(Array(String(line.prefix(lineLength)).utf8), maxLength: lineLength)
      line = String(line.dropFirst(lineLength))
      lineCount += 1
    }
  }
  func flushGroup(_ group: [UInt8]) {
    addText(Data(group).base64EncodedString())
  }
  while input.hasBytesAvailable {
    let n = input.read(&buffer, maxLength: buffer.count)
    if n < 0 { throw CocoaError(.fileReadUnknown) }
    if n == 0 { break }
    pending.append(contentsOf: buffer[0..<n])
    let groups = pending.count / 3
    if groups > 0 {
      flushGroup(Array(pending[0..<(groups * 3)]))
      pending.removeFirst(groups * 3)
    }
  }
  if !pending.isEmpty {
    flushGroup(pending)
  }
  if !line.isEmpty {
    if lineCount > 0 { _ = output.write(Array("\r\n".utf8), maxLength: 2) }
    _ = output.write(Array(line.utf8), maxLength: line.utf8.count)
  }
}

Peak memory is one read buffer plus the current line, no matter how large the file, and the wrapped output matches the one-shot .lineLength76Characters spelling exactly. The same multiple-of-three rule, with the roles reversed, is the one the streaming decoder in the sister article leans on, so the two sides of the trip share one arithmetic truth.

Big Payloads and the Memory Bill

Let us do the arithmetic you will need the next time someone asks "can we base64 this?" Every three input bytes become four output characters, so the size multiplies by 4/3: a 100 kilobyte file becomes a 133,336-character string, a 10 megabyte file becomes 13,333,336 characters, and so on. Padding adds at most two characters at the very end, a rounding error on anything bigger than a few bytes, and empty input is the only exemption, where the tax office grants a single free pass and the result is the empty string. Three practical consequences. First, budget before you start: if your payload is already near a limit (a URL's roughly 2,000-character comfort zone, a JSON field's contract, a database column's width), divide the limit by 1.33 before you encode, not after (and by 1.37 when wrapping is involved). Second, while you pack, you hold the original bytes and the packed string at the same time, so the working set is about 2.33 times the original, and the streaming functions above are the escape hatch when that number stops being comfortable. Third, the tax is one-way in practice: you pay it when you pack and your bytes come home when someone unpacks, so the real question is never "is base64 expensive?" but "does the text-only road I am on demand it?".

The Mistakes That Bite

  • The failable charset step. String.data(using:) can answer nil (try .ascii with an accented character), and force-unwrapping it is the classic upgrade of a bad input into a crashed app. Guard the conversion, not just the base64 call, which is the easy part.
  • The CRLF house style. A .lineLength option without a line-ending option produces CRLF by default. If your format wants LF-only and you forgot the option, your output carries carriage returns it was never supposed to have.
  • The CR-only trap. .endLineWithCarriageReturn alone produces old-Mac-style CR-only line endings. If you meant CRLF (and for MIME you do), pass both line-ending options.
  • Wrapping inside JSON. A raw line break inside a JSON string is invalid JSON, full stop. Wrapped base64 interpolated into a document is a syntax error with a base64 accent. Keep wrapped output in e-mail bodies and certificate files.
  • The BOM hitchhiker. The plain .utf16 conversion prepends a two-byte BOM that travels into your packed output and confuses decoders that did not expect it. Use .utf16LittleEndian or .utf16BigEndian when you need UTF-16 without the tag.
  • Dialect drift. Standard and base64url are different alphabets, and the RFC says so in writing. A + that survives into a query string becomes a space; a - that reaches a lenient standard decoder gets deleted. Pick the dialect at the boundary and keep it.
  • Case is a letter. The alphabet distinguishes A from a. A case-folded copy-paste or an enthusiastic uppercasing call silently corrupts the data, because both versions still pass every alphabet check. Base64 is case-sensitive the way a passport number is.
  • The alignment rule. Streaming encoders must cut chunks on multiples of three bytes. A misaligned chunk changes the output, and the change is silent: the string still decodes, to the wrong data.
  • The double wrap. Two layers that both encode produce base64 of base64. The reader who decodes once sees letters where bytes should be, and the incident writes itself.
  • The availability wall. The new native options (.base64URLAlphabet, .omitPaddingCharacter) exist on the newest Apple SDKs and in open-source Foundation behind an availability marker, but not on every toolchain your CI will touch. If you adopt them, guard with availability checks so the same source builds on older Xcode and on Linux. On a current stable toolchain today, the four-line extension compiles everywhere the options do not.
  • Base64 is not encryption. If the requirement is confidentiality, you have picked the wrong tool by an entire category. Base64's job is making bytes travel, and it does exactly that job, no more.

How to Ship It

  • Encode bytes, not wishes. Decide the byte form before you call the method, UTF-8 by default and named explicitly when it is not, and guard the failable data(using:) step, because that is where data actually goes missing.
  • Unwrapped by default, wrapped by contract. The plain single-line output is correct for JSON, APIs, and most databases; reach for the 64/76 wrapping options only when the receiving format demands them, and pay for both line-ending options when you mean CRLF.
  • One dialect per boundary. Standard base64 for text-centric destinations, base64url for anything that will touch a URL or a filename, never the two in the same document. Write the conversion once, name it honestly, and reuse it.
  • Budget the surcharge. Multiply by 4/3 before you start (by 1.37 when wrapping is in play), and stream with 3-byte-aligned chunks when the payload is big enough to make the working set uncomfortable.
  • Do not use packing tape as a lock. If the requirement is secrecy, stop at the base64 shelf and take encryption instead.

A Short History of Packing

The alphabet you pack with and the line lengths you wrap to are fossils from four decades of arguments about how much binary can survive a text-only road, and Swift's position in that history is short but interesting:

  • 1980s, the same-machine era. The first encoders of this family existed to move files over dial-up between systems that assumed the other end was a machine like theirs. uuencode on UNIX used uppercase letters, digits, and punctuation, and its designers found a trick that saved computing power: the alphabet sits at consecutive ASCII positions, so encoding was literally "add 32" with no lookup table. BinHex, the TRS-80 and classic Macintosh cousin, went further and simply deleted the visually confusable characters (7, O, g, o) from the alphabet, because a human reading a printout should not be able to misread a byte.
  • 1987, the alphabet gets an address. RFC 989, the first Privacy-Enhanced Mail specification, standardized the exact 64 characters you type today, wrapped output at 64 characters per line, and used = for padding and * to mark encoded-but-unencrypted data. Every PEM-style block you have ever pasted into a server config is a descendant of this document.
  • 1996, the liberal era. MIME (RFC 2045) took the alphabet for e-mail attachments and moved the wrap to 76 characters, adding the rule that made wrapping safe to produce: decoders should ignore the line breaks. Encoders learned to wrap; decoders learned to forgive. Swift's 76-character option is a living souvenir of exactly this argument.
  • 2003 to 2006, the rules harden. RFC 3548 (2003) declared that an encoder must not emit characters outside its alphabet or skip padding; RFC 4648 (October 2006) settled the family and added the URL-safe alphabet, explicitly so long identifiers could live in URLs without percent-escaping every special character. The "no padding in the URL dialect" convention was born in the same document, because a pad character in a URL typically becomes %3D, which defeats the purpose.
  • 2013 to 2014, the API is already here. Apple's NSData class had packed base64 for years, and the options-based API with the four wrapping options arrived in iOS 7, in 2013, before Swift existed at all. When Swift 1.0 arrived on September 9, 2014, it inherited a total encoder with four wrapping options and a 64-letter alphabet from 1987, and the personality has not changed since.
  • December 3, 2015, the toolchain leaves the building. Swift was open-sourced that day, and Foundation's base64 crossed to Linux and later Windows with it. "Base64 encoding in Swift off an Apple machine" is barely a decade old: a very young guest at a party that started in 1987.
  • 2023 to 2026, the rewrite and the URL dialect. The Foundation rewrite (the swift-foundation project) moved Data into a pure-Swift core, and in 2025 a community pitch added native base64url and padding-omission options. As of this writing, the newest Apple SDKs (26.4) ship the encoding options, the rest of the family is maturing in open-source Foundation behind availability markers, and the community extension remains the portable bridge in the meantime.

Little Delights

  • One megabyte packs to exactly 1,333,336 base64 characters, the 4/3 tax plus a single padding character, down to the digit. The only input that escapes the tax entirely is the empty one: nothing in, nothing out.
  • The encoder is total in a way the decoder is not. It never returns nil, never throws, never refuses. The only failure in the whole pipeline lives upstream, in the charset step, which is why the method feels so much calmer than its cousin.
  • Encode the word héllo in UTF-8 and it becomes aMOpbGxv; encode it in UTF-16 little-endian and it becomes aADpAGwAbABvAA==. Same word, two different passports, both valid, neither interchangeable.
  • The test word of the base64 world is foobar, and it packs to Zm9vYmFy. If you have ever seen a base64 example in the wild, there is a fair chance foobar was involved.
  • The famous 1x1 transparent GIF is 42 bytes and opens with the magic word GIF89a, which is why the prefix R0lGODlh shows up in more codebases on Earth than almost any other base64 string.
  • Your Codable struct has probably been sending base64 for years without you noticing: JSONEncoder's default Data strategy packs with standard base64, which is why a Data field crosses the wire as a padded string instead of an array of numbers.
  • Padding never exceeds two characters, ever. A payload of 1 byte ends in ==, a payload of 2 bytes ends in =, and a payload of 3 bytes ends in nothing. The entire grammar of the final group fits on a fingernail.
  • Swift is 27 years younger than the alphabet it packs with. The language shipped in 2014; the 64 letters were standardized in 1987 and have not changed since.

That is the complete packing toolbox: one total method, one failable step that comes before it, four wrapping options with a CRLF house style, a four-line base64url extension, a 3-byte alignment rule for streaming, and a 4/3 surcharge that is the price of admission to the text-only road. Encoding is where you pay base64's bill, and you now know every line item before you sign. The moment you flip the trip around and start opening what other people packed, the nil returns, the whitespace verdicts, and the lenient knob's blind spot take the stage. The related decoding article runs the complete show on that half of the round trip, so when the letters start arriving, you will already know exactly how to open them.

Last updated: 2026-08-30

Related article: Base64 Decoding in Swift: A Complete Guide