Base64 Decoding in Kotlin: A Complete Guide
You are staring at a value that refuses to be read: SGVsbG8sIFdvcmxkIQ==. A run of letters and digits, the occasional + or / mixed in, and usually one or two = characters hanging off the end. This is Base64, and this guide is about turning it back into whatever it used to be - a sentence, an image, a certificate, a binary blob - the Kotlin way. A one-line refresher before we dive in: Base64 packs every three bytes into four characters drawn from a 64-symbol alphabet, and a short tail of = padding marks where the real data stopped. The full format tour lives on the home page, so we only spend a sentence here, and one more: because four characters carry what three bytes carried, the text form is roughly a third longer than the original data.
The good news about Kotlin: you do not need any package at all. The standard library has shipped its own Base64 implementation for years, it has been fully stable since Kotlin 2.2, and it runs on every platform Kotlin runs on, from your laptop JVM to an Android phone to Node.js to a WASI edge function. Everything below works with the Kotlin that comes with your project.
The Good News First: What You Actually Need
There is no base64 artifact to add to Gradle, no NuGet-style package, no npm module. The class you want is kotlin.io.encoding.Base64, part of the Kotlin standard library itself. If you can write println, you can decode Base64. Three APIs can do Base64 work in a Kotlin project, and picking the right one is the first real decision:
| API | Where it runs | When to reach for it |
|---|---|---|
kotlin.io.encoding.Base64 |
Every Kotlin platform: JVM, Android, JS, Native, Wasm | Default choice. Stable since Kotlin 2.2, multiplatform, modern API |
java.util.Base64 |
JVM only (Java 8+; on Android, API 26+) | JVM-only codebases that already live in Java interop land |
android.util.Base64 |
Android only (API 8+) | Legacy Android code, or when you specifically need its flag constants |
Two version notes worth knowing. First, the standard library class first appeared in Kotlin 1.8.20 (April 2023) behind an @ExperimentalEncodingApi gate; Kotlin 2.2.0 (June 2025) made it stable and added the padding options and the PEM instance. So on Kotlin 2.2 or newer - including the current stable line, 2.4.x - you can use everything in this guide with zero annotations. Second, if your project pins a Kotlin version between 1.8 and 2.1, the same class exists but is marked experimental, and the compiler will not let you use it without an @OptIn annotation on the function.
One installation trap that has cost more than one afternoon: the kotlin package in the Debian and Ubuntu repositories is version 1.3.31, which predates the standard library Base64 API entirely, so it cannot compile a single example in this article. Grab the compiler from the Kotlin releases on GitHub or from SDKMAN instead, and in Gradle projects pin the plugin explicitly:
plugins {
kotlin("jvm") version "2.4.10"
}
Your First Decode: Two Lines and a Bytes Result
The whole ceremony fits in two statements, and the classic TWFu string is a fine place to start:
import kotlin.io.encoding.Base64
fun main() {
val packed = "TWFu"
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // Man
}
Read that slowly, because three design decisions are hiding in it. First, Base64.decode(...) without any .Default is not a typo: Default is the companion object of the class, so calling the function on the class itself is shorthand for calling it on Base64.Default. You will also see Base64.Default.decode(...) in older tutorials and it means exactly the same thing. Second, and this matters more than it looks: decode hands you a ByteArray, never a String. The payload could be a JPEG, an X.509 certificate, or a sentence, and the API refuses to guess which, so the bytes-to-text hop is a separate, deliberate step. Third, that step is where the character set decision lives, and it is where most "my Base64 came back as garbage" bugs are born. We get there in a moment; first, a round trip to prove the decode is faithful:
import kotlin.io.encoding.Base64
fun main() {
val original = "Hello, World!".encodeToByteArray()
val packed = Base64.encode(original)
val back = Base64.decode(packed)
println(packed) // SGVsbG8sIFdvcmxkIQ==
println(back.contentEquals(original)) // true
}
Four Schemes, Four Personalities
The class is never instantiated; you pick one of four ready-made instances, and each one decodes with a different temperament:
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello?".encodeToByteArray()
println(Base64.Default.encode(data)) // SGVsbG8/
println(Base64.UrlSafe.encode(data)) // SGVsbG8_
println(Base64.Mime.encode(data)) // SGVsbG8/
println(Base64.Pem.encode(data)) // SGVsbG8/
}
| Instance | Alphabet | How it decodes |
|---|---|---|
Base64.Default |
A-Z a-z 0-9 + / |
Strict: any character outside the alphabet throws; padding is required |
Base64.UrlSafe |
A-Z a-z 0-9 - _ |
Strict, but against the URL alphabet; a + or / in the input throws |
Base64.Mime |
A-Z a-z 0-9 + / |
Lenient: ignores line separators and other non-alphabet characters, but nothing may follow the = padding; padding is required |
Base64.Pem |
A-Z a-z 0-9 + / |
Lenient, same rules as Mime; this is the PEM/PKI flavor of the same alphabet |
The lenient/strict split is the single most useful thing to internalize. Default and UrlSafe treat any foreign character as a crime scene and throw immediately. Mime and Pem shrug at line breaks, spaces, and stray punctuation - because that is exactly what real email and certificate files contain - yet they are not unlimited: the moment a data character shows up after the padding, even they throw. You will see the exact error messages in the failure guide later in this article.
One more consequence of personalities: a scheme cannot read another scheme's output. Feed a base64url token to Base64.Default and the - character is not in its alphabet, so you get IllegalArgumentException: Invalid symbol '-'(45) at index .... When in doubt about where a string came from, pick the scheme that matches the producer, not the one that matches your mood.
URL-Safe Base64 and JWTs
Two characters in the standard alphabet cause trouble the moment data has to travel through a URL. A + in a query string is routinely reinterpreted as a space by the time anything reads it, and / is a path separator, so it cannot appear in a URL segment at all. RFC 4648, section 5, solves this by swapping the last two alphabet symbols: + becomes - and / becomes _. The name you will hear most is base64url, and in Kotlin it is Base64.UrlSafe.
The biggest consumer of base64url is the JSON Web Token. A JWT in its compact form is three base64url parts joined by dots: header.payload.signature. RFC 7515 specifies these parts as base64url without padding, which is a second difference from the plain alphabet, not just the characters. Here is a token being unpacked for inspection:
import kotlin.io.encoding.Base64
fun main() {
val token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
val (header, payload, signature) = token.split(".")
val lenient = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(lenient.decode(header).decodeToString())
// {"alg":"HS256"}
println(lenient.decode(payload).decodeToString())
// {"sub":"1234567890","name":"John Doe"}
println(signature.length) // 43
}
Two things to notice. The token parts carry no padding, but Base64.UrlSafe out of the box demands padding, so the withPadding(PRESENT_OPTIONAL) line is doing real work: it accepts padded and unpadded input alike. And the split(".") plus destructuring is just plain Kotlin doing what the format asks of it. One serious warning: unpacking a JWT is for looking at a token, not for trusting it. The header and payload are plain data after decoding; only a verified signature says the token is genuine, and for that you want a real JWT library, not hand-rolled string splitting.
Padding Modes and the Strictness Dial
Padding is not a fixed fact about Base64 in Kotlin; it is a setting. Every instance carries a PaddingOption, all four preset instances start on PRESENT, and withPadding hands you a new instance with a different setting while leaving the original untouched. Here is the dial, option by option:
| Option | Input without padding | Input with correct padding |
|---|---|---|
PRESENT (default everywhere) |
Throws | Decodes |
ABSENT |
Decodes | Throws |
PRESENT_OPTIONAL |
Decodes | Decodes |
ABSENT_OPTIONAL |
Decodes | Decodes |
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello".encodeToByteArray()
println(Base64.Default.withPadding(Base64.PaddingOption.ABSENT).encode(data))
// SGVsbG8
println(Base64.Default.encode(data))
// SGVsbG8=
val eitherWay = Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(eitherWay.decode("SGVsbG8").decodeToString()) // Hello
println(eitherWay.decode("SGVsbG8=").decodeToString()) // Hello
}
On the decoding side, PRESENT_OPTIONAL is your safety net: it is the option that says "I do not know whether the sender padded, and I intend to keep working." The error messages for the other combinations are unusually helpful, so you will recognize them instantly when a strict decoder meets the wrong input: missing padding under PRESENT produces The padding option is set to PRESENT, but the input is not properly padded, and padding under ABSENT produces The padding option is set to ABSENT, but the input has a pad character at index 7. One behavior deserves a callout because it surprises people: a double padding like SGVsbG8== is not "extra, but fine". The first = ends the data, and the second one is a character where data was expected, so even the most lenient decoders refuse it.
There is also a version history hidden here. If you inherited code written against the experimental 1.8.x API, remember that the old decode accepted input with or without padding. When Kotlin 2.2 stabilized the API, Default moved to the strict PRESENT rule, so a once-working unpadded input now throws on upgrade. The fix is one line: withPadding(Base64.PaddingOption.PRESENT_OPTIONAL), or normalize your inputs before decoding.
From Bytes to Text: Charsets and Unicode
Once you have your ByteArray, the question is what it means. If the payload is text, the default answer is decodeToString(), which interprets the bytes as UTF-8 and works on every platform. For the common case of modern APIs, emails, and web data, that is all you will ever need, and emoji included:
import kotlin.io.encoding.Base64
fun main() {
val original = "héllo 😀"
val packed = Base64.encode(original.encodeToByteArray())
println(packed) // aMOpbGxvIPCfmIA=
println(Base64.decode(packed).decodeToString()) // héllo 😀
}
The moment the sender used anything other than UTF-8, though, the charset decision is on you. Kotlin's built-in text conversions are UTF-8 only on purpose: decodeToString() has no charset parameter, and there is no string-to-bytes function with one either. On the JVM you drop down to the platform charset API, which is honest and explicit:
import kotlin.io.encoding.Base64
import java.nio.charset.Charset
fun main() {
val latinOne = "héllo".toByteArray(Charsets.ISO_8859_1)
val packed = Base64.encode(latinOne)
println(packed) // aOlsbG8=
val asUtf8 = Base64.decode(packed).decodeToString()
val asLatin = String(Base64.decode(packed), Charsets.ISO_8859_1)
println(asUtf8) // h?llo (the é byte is not valid UTF-8)
println(asLatin) // héllo
val byName = String(Base64.decode(packed), Charset.forName("ISO-8859-1"))
println(byName) // héllo
}
That ? in the middle line is not a font problem; it is U+FFFD, the Unicode replacement character, standing in for a byte that does not form valid UTF-8. If you see a string of them after decoding, your payload is fine - your character set assumption is not. Note also the asymmetry that bites people: on the encoding side the JVM extension toByteArray(charset) exists, on the decoding side the matching constructor is String(bytes, charset). Neither takes a charset name; for that you need Charset.forName("..."), which throws UnsupportedCharsetException for a made-up name, so a typo in a config value fails fast rather than silently picking a different encoding.
While we are in byte-land, one Kotlin-specific trap: a Char is a 16-bit value, and toByte() on it silently keeps only the low eight bits. If you hand-roll bytes from characters, "中".first().code.toByte() gives you 45, a number that has nothing to do with the character. The correct path is always encodeToByteArray(), which does the real encoding work - the same character is three UTF-8 bytes, and its Base64 form is 5Lit. Let the standard library encode; never pack characters into bytes by hand.
Files, Substrings and Big Inputs
Base64 data is not always a neat string in memory. Sometimes it is a file, a slice of a bigger response, or too large to hold all at once. Kotlin gives you all three doors.
Files are the boring case in the best way: read the bytes, decode, done. Both of the standard file APIs work, whichever your project already uses:
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readBytes
fun main() {
val fromFile = File("payload.b64").readBytes()
println(Base64.decode(fromFile.decodeToString()).size) // decoded byte count
val fromPath = Path("payload.b64").readBytes()
println(Base64.decode(fromPath.decodeToString()).size) // same number
}
Substrings are where the CharSequence overloads earn their keep. decode accepts any character sequence with a start and end index, so you can hand it a slice of a long response body without making a copy of the slice first:
import kotlin.io.encoding.Base64
fun main() {
val body = "prefix junk SGVsbG8= trailing junk"
val bytes = Base64.decode(body, 12, 20)
println(bytes.decodeToString()) // Hello
}
If you already know the output size and want to reuse a buffer, decodeIntoByteArray writes into a destination array of your choosing and tells you how many bytes it wrote. Give it a buffer that is too small and it throws IndexOutOfBoundsException with the required capacity in the message, so the error doubles as your sizing hint.
For genuinely large streams on the JVM, there is a third door: the streaming decoders. They are still marked experimental - hence the opt-in annotation - and they exist only for the JVM, but they decode on the fly instead of holding everything in memory:
import java.io.ByteArrayInputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.decodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
val stream = ByteArrayInputStream("SGVsbG8gV29ybGQh".toByteArray())
stream.decodingWith(Base64.Default).use {
println(it.readBytes().decodeToString()) // Hello World!
}
}
Two practical details. The extension functions live at the top level of the package, so you import them by name (a star import works too, but names are kinder). And the decoder treats the padding as a hard stop: if the underlying stream keeps going after the Base64 section, reading from the decoded stream ends at the = and the leftover bytes remain available in the original stream. That makes it tidy for formats that tack Base64 in front of something else.
In the Trenches: HTTP APIs and JSON Bodies
JSON cannot carry raw bytes - it is a text protocol - so APIs that need to move binary (images, certificates, arbitrary blobs) almost always wrap it in Base64 inside a string field. The pattern is: parse the JSON, take the field, decode. With the official serialization library the JSON part is two annotations away:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class ImageResponse(val name: String, val data: String)
fun main() {
val body = """{"name":"icon.png","data":"iVBORw0KGgo="}"""
val response = Json.decodeFromString<ImageResponse>(body)
val bytes = Base64.decode(response.data)
println("${response.name}: ${bytes.size} bytes") // icon.png: 8 bytes
}
This example needs the serialization plugin and library, added once to the build:
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
Without the library the same idea works on the raw string, which is handy for quick scripts: pull the field out with substringBetween and decode it. The pitfalls are the familiar API ones: the field may actually be a full data URL (with the data:image/png;base64, prefix, handled later in this article), the payload may be MIME-wrapped with line breaks, and the size can be several times the original binary, so watch your memory budget on large responses.
In the Trenches: Email and MIME-Wrapped Input
Email is a 7-bit text world, and RFC 2045's answer to binary attachments is Base64 with a twist: the encoded output must be wrapped so no line runs longer than 76 characters. If you have ever received an attachment as text, that is why it looks like an indented column of Base64. For exactly this input, Base64.Mime is the right decoder, because it ignores line separators and other non-alphabet characters as it goes:
import kotlin.io.encoding.Base64
fun main() {
val wrapped = "SGVs\nbG8=\r\n"
println(Base64.Mime.decode(wrapped).decodeToString()) // Hello
val withJunk = "Y@{mFz!Z!TY}0"
println(Base64.Mime.decode(withJunk).decodeToString()) // base64
}
The leniency is real but bounded. Wrap the input, sprinkle in a space or two, no problem. Append a data character after the final =, though, and even Mime throws: Symbol 'e'(145) at index 7 is prohibited after the pad character. And remember Mime still requires the padding to be present and correct; a MIME-decoder that also swallowed missing padding would be asking for trouble. The practical recipe for messy incoming email payloads is Mime decode first, and if that throws, look at the message - it tells you exactly which symbol, at which index, broke the rules.
In the Trenches: Images and Data URLs
A data URL is the web's way of inlining a file directly in a document: a media type, a base64, marker, and the payload, all in one string. Browsers, CSS, and embedded UIs love them for small assets - icons, avatars, placeholder graphics - because there is no second request to make. The format looks like this:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==
Decoding one in Kotlin is a string operation followed by a Base64 decode. The prefix carries no secret; everything after the last comma is the payload:
import kotlin.io.encoding.Base64
fun main() {
val dataUrl = "data:image/png;base64,iVBORw0KGgo="
val mediaType = dataUrl.substringBefore(";")
val packed = dataUrl.substringAfter("base64,")
val bytes = Base64.decode(packed)
println(mediaType) // data:image/png
println(bytes.size) // 8
println(bytes.contentToString()) // [-119, 80, 78, 71, ...]
}
That first byte, -119 (which is 0x89), followed by the letters PNG, is the magic number that identifies a PNG file. Checking the first four or eight bytes after decoding is a cheap way to confirm a data URL really contains what its prefix claims. Two honest caveats: Base64 adds roughly a third to the size, so a data URL is a size trade you make against a network round trip, and for anything large you are usually better off serving the file from a real URL and letting the cache do its job.
In the Trenches: Configuration, Environment Variables and Databases
Base64 shows up in configuration files and environment variables whenever a binary value must ride in a text-only channel: a small embedded icon in a properties file, a token stored in an env var on a container, a byte blob parked in a text column because the schema predated a proper binary type. The decode side is the same two steps everywhere - read the text, decode it:
import kotlin.io.encoding.Base64
fun main() {
val line = "icon: UE5HREFUQQ=="
val packed = line.substringAfter("icon: ").trim()
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // PNGDATA
val fromEnv: String? = System.getenv("MY_ICON_B64")
if (fromEnv != null) {
println(Base64.decode(fromEnv).size)
}
}
The pitfall in this whole neighborhood is one word: Base64 is not encryption. It is a transport trick, not a lock. Nobody should read a Base64 value and think the data inside is hidden; it is one function call from visible, and it is visible in every log line you write. If a value is sensitive, keep it sensitive end to end - a secret store, an encrypted column, whatever your stack provides - and use Base64 only to make the bytes travel through text, not to protect them.
In the Trenches: The Command Line
The oldest use case of all: turning a Base64 blob on the command line into a file. A complete tool is eight lines of Kotlin, because the standard library does the heavy lifting. Compile it once with the Kotlin compiler and it is yours forever:
import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
val packed = if (args.isNotEmpty()) args[0] else readlnOrNull().orEmpty()
val bytes = Base64.decode(packed.trim())
File("decoded.bin").writeBytes(bytes)
println("Wrote ${bytes.size} bytes to decoded.bin")
}
Run it with an argument for a one-off value, or pipe a file into it for batch work: the program reads the first argument if present and falls back to standard input otherwise. The trim() is doing quiet duty here, because shell arguments and pasted values love to arrive with stray whitespace that the strict decoder would reject. And if your payloads are base64url, swap Base64.decode for Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode and the tool is ready for tokens too.
A Field Guide to Decoding Failures
Every decoder in this guide fails with one of two exception types, and every message is specific enough to tell you exactly what went wrong. Here is the full map, with the exact messages the standard library produces:
| Situation | Exception | Message (as produced) |
|---|---|---|
| Character outside the alphabet (space, newline, wrong scheme's symbol) | IllegalArgumentException |
Invalid symbol ' '(40) at index 5 |
| Data character after the padding | IllegalArgumentException |
Symbol 'e'(145) at index 7 is prohibited after the pad character |
Missing padding while the option is PRESENT |
IllegalArgumentException |
The padding option is set to PRESENT, but the input is not properly padded |
Padding present while the option is ABSENT |
IllegalArgumentException |
The padding option is set to ABSENT, but the input has a pad character at index 7 |
| Index out of the source's bounds | IndexOutOfBoundsException |
startIndex: 0, endIndex: 100, size: 8 |
startIndex greater than endIndex |
IllegalArgumentException |
startIndex: 3 > endIndex: 2 |
Destination buffer too small for decodeIntoByteArray |
IndexOutOfBoundsException |
The destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8 |
Notice the pattern in the first two rows: the message names the offending symbol, its numeric code in parentheses, and its index. That is a debugging gift. When a decode throws in production, log the input's first few dozen characters and the index from the message and you can almost always find the culprit in seconds, whether it is a pasted newline, a truncated payload, or a base64url string that wandered into a standard decoder.
Pitfalls That Specifically Hurt Kotlin Developers
- Assuming the payload is text.
decodereturns aByteArrayon purpose. CallingdecodeToString()on a JPEG because "it probably is text" gives you a wall of replacement characters. Decide what the bytes are before you convert them. - Copy-paste whitespace. The default decoder is strict, and a value lifted from a chat message or a log almost always arrives with a trailing newline or a leading space. Trim before decoding, or decode through
Mime, or accept theIllegalArgumentExceptionand handle it. - Upgrading from the experimental era. Code written for Kotlin 1.8 through 2.1 carried
@OptIn(ExperimentalEncodingApi::class)annotations and relied on padding being optional. After 2.2 the same input can throw. The fix isPRESENT_OPTIONAL, or cleaning inputs before they reach the decoder. - Matching the scheme to the wrong producer. A JWT part decoded with
Base64.Defaultfails on its-and_characters; a standard-alphabet payload decoded withUrlSafefails on+and/. The exception names the exact symbol, but the fix is knowing where the string came from. - The charset gap.
decodeToString()is UTF-8 only, with no overload for other encodings. If the sender used Latin-1 or Windows-1252, plan onString(bytes, charset)on the JVM, and expect U+FFFD replacement characters as the symptom when you forget. - The distribution-package compiler.
apt install kotlinon Debian and Ubuntu serves 1.3.31, from before this API existed. If your examples suddenly refuse to compile with "unresolved reference", check which compiler is actually on the PATH.
Best Practices for Decoding
- Decode to bytes first, interpret second. Keep
Base64.decodeand text conversion as separate steps. It makes the charset explicit, it keeps binary payloads binary, and it makes tests trivial: compare byte arrays, not strings. - Pick the instance that matches the producer. JWT and URL-bound data mean
UrlSafe; email and PEM files meanMimeorPem; everything else starts atDefault. The lenient decoders are for known-messy input, not a general safety net. - Normalize untrusted input once, cheaply. A
trim()and, where the format is known to be clean, a whitespace strip, before a strict decode catches more real-world failures than any amount of try-catch will. A small helper with aPRESENT_OPTIONALfallback is a good pattern for values from unknown sources. - Budget the size before you allocate. Decoded output is at most three quarters of the input length (four symbols carry three bytes), so a quick length check tells you the destination size before you decode, which is exactly what you want before filling a pre-allocated buffer or accepting a multi-megabyte string.
- Trust the error message. The standard library reports the symbol, its code, and its index. Log the neighborhood of that index for untrusted input and stop guessing.
- Do not decode to hide things, and do not decode to prove things. Base64 is a transport encoding. It adds no secrecy and no integrity; if you need either, that is cryptography's job, not the decoder's.
How Base64 Got Into Kotlin
Base64 is older than Kotlin by a few decades - the MIME specification that gave the 76-character line rule dates to 1997, and the alphabet itself to RFCs of the mid-1990s - but the Kotlin-specific story is short and recent. The kotlin.io.encoding package arrived in Kotlin 1.8.20 in April 2023, carrying Base64 with three instances - Default, UrlSafe, and Mime - behind the @ExperimentalEncodingApi annotation, along with the JVM-only streaming extensions that are still experimental today. For two years, using it meant an opt-in line in every function and a small chance the API would move.
Kotlin 2.2.0, released in June 2025, changed the contract. The whole API became stable in one release, the Pem instance joined the family (the RFC 1421, 64-character-line variant used around PKI), and withPadding with its four PaddingOption values arrived to replace the old fixed behavior - which is precisely why padding-optional code from the 1.8 era needs attention after an upgrade. The 2.2 release also stabilized the sibling HexFormat class in kotlin.text, the hex formatting API that has been experimental since Kotlin 1.9, so byte-level textual encodings now have a settled home in the standard library. And on a maintenance note: since Kotlin 2.4.0 the JVM standard library ships with an 18-month support window per release line, which is one more reason a project on the current 2.4.x line can treat this API as a fixed point rather than a moving one.
Fun Facts
- The companion object does a job. Because
Defaultis the companion ofBase64, the class name doubles as the default instance:Base64.decode(x)andBase64.Default.decode(x)are the same call. It is the reason the two-line example at the top of this article stays at two lines. - String decoding gets a speed hack on the JVM. The common decoding loop works on bytes, but Kotlin strings are character sequences. The JVM implementation sidesteps the conversion by reinterpreting a
String's characters as single-byte ISO-8859-1 values before the shared loop runs - a trick the source code comments claim is up to ten times faster than the common path, and it is whydecode(String)feels instant even on long payloads. - The package name is a hint. You will find this API in
kotlin.io.encoding, not inkotlin.text, because the whole point is that the data is bytes - input and output are I/O-shaped, and the text is only what happens to the result afterwards. - The error messages include the character's code.
Symbol 'e'(145)reports the code point of the offending symbol, not just its glyph. Handy when the culprit is whitespace:' '(40)tells you it was a space long before you suspect one. - PEM arrived late.
Base64.Pemwas not part of the original 1.8.20 API; it showed up with the 2.2 stabilization. If a blog post from 2023 or 2024 lists only three instances, it is not wrong, it is just two releases out of date. - It is written per platform, not delegated. The standard library implements the codec separately for each target with expect/actual functions. On the JVM there is even a commented-out optimization that would hand the work to
java.util.Base64, disabled behind an open compiler issue, which is why the Kotlin implementation's behavior is the reference behavior on every platform.
Wrap-Up
Decoding Base64 in Kotlin comes down to a short list of deliberate choices: the instance that matches where the data came from, the padding mode that matches how it was sent, the buffer or stream that matches how big it is, and the character set that matches what it means. The standard library hands you all four as plain functions with no dependencies, and its error messages are specific enough that a failure is a diagnosis, not a mystery. The opposite direction - choosing the right scheme, padding, and line wrapping when you are the one producing the Base64 - has its own decisions and its own traps, and the related article on the sister site covers Base64 encoding in Kotlin in depth.
Last updated: 2026-08-30
Related article: Base64 Encoding in Kotlin: A Complete Guide