ZetCode

Python base64

last modified October 29, 2026 Python 3.15

In this article we show how to encode binary data as text with the base64 module.

Many channels can carry only text: email, JSON documents, URLs, HTTP headers, XML, or configuration files. Base64 represents arbitrary bytes with 64 printable ASCII characters, so binary data such as images, keys, and signatures can travel through them. The encodings are defined in RFC 4648.

The base64 module implements Base64, Base32, Base16, and the Base85 family. Python 3.15 added several options: padded to omit padding, wrapcol to split output into lines, ignorechars to skip selected characters, and canonical to reject non-canonical input. The encoders are also much faster than in earlier versions.

The functions take bytes-like objects and return bytes. Decoders also accept ASCII strings. For converting between text and bytes, see Python encode/decode.

Encoding and decoding

The b64encode and b64decode functions work with the standard Base64 alphabet.

first_encode.py
import base64

text = 'Hello, Python!'

encoded = base64.b64encode(text.encode('utf-8'))
print(encoded)
print(encoded.decode('ascii'))

decoded = base64.b64decode(encoded)
print(decoded.decode('utf-8'))

print(base64.b64encode('Žltý kôň'.encode()))
print(base64.b64decode('xb1sdMO9IGvDtMWI').decode())

Base64 works with bytes, not text. We encode the string to UTF-8 bytes first. The result is a bytes object that contains only ASCII characters, so we can decode it to a str when we need text.

print(base64.b64decode('xb1sdMO9IGvDtMWI').decode())

Decoding reverses the process; the decoder accepts a string, too. Non-ASCII text works because Base64 sees only the UTF-8 bytes.

$ python first_encode.py
b'SGVsbG8sIFB5dGhvbiE='
SGVsbG8sIFB5dGhvbiE=
Hello, Python!
b'xb1sdMO9IGvDtMWI'
Žltý kôň

How Base64 works

Base64 splits the input into groups of three bytes, 24 bits, and splits each group into four 6-bit numbers. Each number from 0 to 63 is an index into the alphabet.

manual_b64.py
import base64

ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'

def encode(data):
    out = []
    for i in range(0, len(data), 3):
        chunk = data[i:i + 3]
        n = int.from_bytes(chunk.ljust(3, b'\0'))
        chars = [ALPHABET[(n >> shift) & 0x3f] for shift in (18, 12, 6, 0)]
        used = len(chunk) + 1
        out.append(''.join(chars[:used]) + '=' * (4 - used))
    return ''.join(out)

data = b'Man'
print(' '.join(f'{b:08b}' for b in data))
n = int.from_bytes(data)
print(' '.join(f'{(n >> s) & 0x3f:06b}' for s in (18, 12, 6, 0)))
print(encode(data))

for sample in [b'Ma', b'M', b'Many hands make light work.']:
    print(encode(sample), encode(sample) == base64.b64encode(sample).decode())

We implement the encoder ourselves and check it against the module. The classic example Man shows the bits: three 8-bit bytes become four 6-bit indexes, 19, 22, 5, and 46, which are the letters TWFu.

used = len(chunk) + 1
out.append(''.join(chars[:used]) + '=' * (4 - used))

The last group can have only one or two bytes. It is padded with zero bits, and the missing characters are replaced with the = padding character, so the output length is always a multiple of four.

$ python manual_b64.py
01001101 01100001 01101110
010011 010110 000101 101110
TWFu
TWE= True
TQ== True
TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu True

Padding and size

Every three bytes of input become four characters of output.

padding.py
import base64

for n in range(1, 8):
    data = b'x' * n
    enc = base64.b64encode(data)
    print(f'{n} bytes -> {len(enc)} chars  {enc.decode()}')

data = bytes(3000)
print(len(base64.b64encode(data)) / len(data))

One leftover byte produces two padding characters and two leftover bytes produce one. Base64 makes data a third larger, which matters when embedding large files in JSON or HTML.

$ python padding.py
1 bytes -> 4 chars  eA==
2 bytes -> 4 chars  eHg=
3 bytes -> 4 chars  eHh4
4 bytes -> 8 chars  eHh4eA==
5 bytes -> 8 chars  eHh4eHg=
6 bytes -> 8 chars  eHh4eHh4
7 bytes -> 12 chars  eHh4eHh4eA==
1.3333333333333333

URL-safe Base64

The + and / characters of the standard alphabet have special meanings in URLs and file paths. The URL-safe alphabet replaces them with - and _.

urlsafe.py
import base64

data = bytes([0xfb, 0xff, 0xbf, 0xfe])

print(base64.b64encode(data))
print(base64.urlsafe_b64encode(data))
print(base64.urlsafe_b64encode(data, padded=False))
print(base64.b64encode(data, altchars=b'-_'))

print(base64.urlsafe_b64decode('-_-_-g'))
print(base64.urlsafe_b64decode('-_-_-g=='))

We pick bytes that produce the two problematic characters. The altchars argument of b64encode replaces them with any two characters.

print(base64.urlsafe_b64encode(data, padded=False))

The = character also needs escaping in URL query strings, so URL-safe Base64 is often used without padding. Since Python 3.15, the padded=False option omits it. The decoder no longer requires the padding either.

$ python urlsafe.py
b'+/+//g=='
b'-_-__g=='
b'-_-__g'
b'-_-__g=='
b'\xfb\xff\xbf\xfa'
b'\xfb\xff\xbf\xfa'

Unpadded input

Before Python 3.15, urlsafe_b64decode required the padding, even though the padding carries no information.

unpadded.py
import base64
import binascii
import sys

token = 'eyJzdWIiOiJqYW5lIn0'
print(sys.version.split()[0], len(token) % 4)

try:
    print(base64.urlsafe_b64decode(token))
except binascii.Error as e:
    print('binascii.Error:', e)

The token has 19 characters, so one padding character is missing. We run the script with Python 3.15 and Python 3.14: the new version decodes the token, while the old one raises an error.

$ python unpadded.py
3.15.2 3
b'{"sub":"jane"}'
$ python3.14 unpadded.py
3.14.0 3
binascii.Error: Incorrect padding

Restoring padding

Code that must run on older versions adds the missing padding before decoding.

portable_padding.py
import base64

def b64url_decode(s):
    return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))

def b64url_encode(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

for s in ['aGk', 'aGVs', 'aGVsbG8', 'aGVsbG8gd29ybGQ']:
    print(s, b64url_decode(s))

print(b64url_encode(b'hello'))

The -len(s) % 4 expression computes how many characters are missing to the next multiple of four. Encoding without padding is a simple rstrip. To make padding required again in Python 3.15, pass padded=True to urlsafe_b64decode.

$ python portable_padding.py
aGk b'hi'
aGVs b'hel'
aGVsbG8 b'hello'
aGVsbG8gd29ybGQ b'hello world'
aGVsbG8

JSON Web Tokens

A JSON Web Token (JWT) consists of three parts encoded with unpadded URL-safe Base64: a header, a payload, and a signature. With the new padded options, the standard library handles the format without helper functions.

jwt_token.py
import base64
import hashlib
import hmac
import json

SECRET = b'my-secret-key'

def b64url(data):
    return base64.urlsafe_b64encode(data, padded=False).decode()

def make_token(payload):
    header = {'alg': 'HS256', 'typ': 'JWT'}
    parts = [b64url(json.dumps(obj, separators=(',', ':')).encode())
             for obj in (header, payload)]
    signing_input = '.'.join(parts).encode()
    signature = hmac.new(SECRET, signing_input, hashlib.sha256).digest()
    return '.'.join(parts + [b64url(signature)])

def read_token(token):
    header, payload, signature = token.split('.')
    expected = hmac.new(SECRET, f'{header}.{payload}'.encode(),
                        hashlib.sha256).digest()
    if not hmac.compare_digest(base64.urlsafe_b64decode(signature), expected):
        raise ValueError('invalid signature')
    return json.loads(base64.urlsafe_b64decode(payload))

token = make_token({'sub': 'jane', 'role': 'editor', 'exp': 1800000000})
print(token)

for part in token.split('.')[:2]:
    print(base64.urlsafe_b64decode(part))

print(read_token(token))

We create and verify tokens signed with HMAC-SHA256 (the HS256 algorithm). The signature is computed over the first two encoded parts joined by a dot.

for part in token.split('.')[:2]:
    print(base64.urlsafe_b64decode(part))

The header and payload are only encoded, not encrypted: anyone can read them. The signature only proves that they were not changed. Real applications should use a maintained library such as PyJWT, which also checks the expiration time and the algorithm.

$ python jwt_token.py
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqYW5lIiwicm9sZSI6ImVkaXRvciIsImV4cCI6MTgwMDAwMDAwMH0.gmH-hYhb08aVXPMzVx-HeOKhUi0C5XJABg_Vw3wvksM
b'{"alg":"HS256","typ":"JWT"}'
b'{"sub":"jane","role":"editor","exp":1800000000}'
{'sub': 'jane', 'role': 'editor', 'exp': 1800000000}

Validating input

By default, b64decode silently discards characters that are not in the alphabet.

validate.py
import base64
import binascii

dirty = 'SGVs bG8s!IHdv\ncmxk'

print(base64.b64decode(dirty))

try:
    base64.b64decode(dirty, validate=True)
except binascii.Error as e:
    print('binascii.Error:', e)

wrapped = 'SGVsbG8s\nIHdvcmxk\n'
print(base64.b64decode(wrapped, ignorechars=b'\n'))

try:
    base64.b64decode(dirty, ignorechars=b'\n')
except binascii.Error as e:
    print('binascii.Error:', e)

The input contains a space, an exclamation mark, and a newline, and still decodes. This is convenient for wrapped text, but it hides corrupted data.

base64.b64decode(dirty, validate=True)

With validate=True, any character outside the alphabet raises binascii.Error.

print(base64.b64decode(wrapped, ignorechars=b'\n'))

The ignorechars parameter, new in Python 3.15, lists characters that are skipped; all other invalid characters are errors. Passing it turns on validation, so we can accept wrapped lines and still reject garbage.

$ python validate.py
b'Hello, world'
binascii.Error: Only base64 data is allowed
b'Hello, world'
binascii.Error: Only base64 data is allowed

Canonical encodings

When the last group is incomplete, some bits of the last character are not used. The encoder sets them to zero, but the decoder ignores them, so several strings decode to the same bytes.

canonical.py
import base64
import binascii

for s in ['aGk=', 'aGl=', 'aGm=', 'aGn=']:
    print(s, base64.b64decode(s))

for s in ['aGk=', 'aGl=']:
    try:
        print(s, base64.b64decode(s, canonical=True))
    except binascii.Error as e:
        print(s, 'binascii.Error:', e)

print(base64.b32decode('NBUQ===='), base64.b32decode('NBUR===='))
try:
    base64.b32decode('NBUR====', canonical=True)
except binascii.Error as e:
    print('binascii.Error:', e)

Four different strings decode to b'hi'. If an application compares encoded values, for example signatures or cache keys, an attacker can produce different strings that mean the same thing.

print(s, base64.b64decode(s, canonical=True))

The canonical=True option, new in Python 3.15, accepts only the encoding the encoder would produce. It works for Base32 and Base85 decoders, too.

$ python canonical.py
aGk= b'hi'
aGl= b'hi'
aGm= b'hi'
aGn= b'hi'
aGk= b'hi'
aGl= binascii.Error: Non-zero padding bits
b'hi' b'hi'
binascii.Error: Non-zero padding bits

Mixed alphabets

The URL-safe decoder has always accepted the standard + and / characters, too. Python 3.15 deprecates this.

mixed_alphabet.py
import base64
import warnings

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter('always')
    print(base64.urlsafe_b64decode('-_-_-g=='))
    print(base64.urlsafe_b64decode('+/+/+g=='))

for w in caught:
    print(f'{w.category.__name__}: {w.message}')

Both calls give the same bytes, but the second one emits a FutureWarning. In a future version, characters from the other alphabet will be discarded. We record the warnings and print them.

$ python mixed_alphabet.py
b'\xfb\xff\xbf\xfa'
b'\xfb\xff\xbf\xfa'
FutureWarning: invalid character '+' in URL-safe Base64 data will be discarded in future Python versions

Line wrapping

Email (MIME) and PEM files limit the length of lines. Since Python 3.15, the wrapcol parameter inserts a newline after the given number of characters.

wrap_lines.py
import base64

data = bytes(range(60))

print(base64.b64encode(data, wrapcol=32).decode())
print()
print(base64.encodebytes(data).decode(), end='')
print()
print(base64.b64encode(data, wrapcol=76) + b'\n' == base64.encodebytes(data))
print(base64.b16encode(data[:24], wrapcol=16).decode())

The older encodebytes function always wraps at 76 characters and adds a trailing newline. With wrapcol=76, b64encode gives the same lines without the final newline. The Base16 and Base32 encoders accept wrapcol, too.

$ python wrap_lines.py
AAECAwQFBgcICQoLDA0ODxAREhMUFRYX
GBkaGxwdHh8gISIjJCUmJygpKissLS4v
MDEyMzQ1Njc4OTo7

AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4
OTo7

True
0001020304050607
08090A0B0C0D0E0F
1011121314151617

Data URIs

A data URI embeds a file directly in an HTML or CSS document. We use a small SVG image.

dot.svg
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><circle cx="10" cy="10" r="8" fill="tomato"/></svg>

The image shows a red circle.

data_uri.py
import base64
import mimetypes
from pathlib import Path

def data_uri(path):
    mime, _ = mimetypes.guess_type(path)
    data = base64.b64encode(Path(path).read_bytes()).decode()
    return f'data:{mime};base64,{data}'

uri = data_uri('dot.svg')
print(uri)
print(f'<img src="{uri[:40]}..." alt="dot">')

The URI consists of the MIME type, the base64 marker, and the encoded data. We guess the MIME type from the file extension with the mimetypes module. Data URIs save HTTP requests for small icons; large files are better served separately, because of the 33% overhead.

$ python data_uri.py
data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGNpcmNsZSBjeD0iMTAiIGN5PSIxMCIgcj0iOCIgZmlsbD0idG9tYXRvIi8+PC9zdmc+Cg==
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz..." alt="dot">

Encoding streams

The encode and decode functions work with binary file objects and process the data in chunks.

stream_file.py
import base64
import io
import os

data = os.urandom(10_000)

src = io.BytesIO(data)
encoded = io.BytesIO()
base64.encode(src, encoded)

text = encoded.getvalue()
print(len(text), text.count(b'\n'))
print(text[:77])

encoded.seek(0)
decoded = io.BytesIO()
base64.decode(encoded, decoded)
print(decoded.getvalue() == data)

We use in-memory files from the io module; real files opened in binary mode work the same way. The output is wrapped into lines of 76 characters, as with encodebytes.

$ python stream_file.py
13512 176
b'tt+FOUojupE8oMsWBkilsiLOAOw0yaFXM4Zm11occOb+2iIqVJNbIfl62NYon4tZarR8+X7gELUy\n'
True

Command line

The module can be run as a script. It encodes or decodes a file, or the standard input when no file is given. The -d option decodes. The output of encoding is wrapped at 76 characters like encodebytes.

$ cat msg.txt
Hello there
$ python -m base64 msg.txt
SGVsbG8gdGhlcmUK
$ python -m base64 msg.txt > msg.b64
$ python -m base64 -d msg.b64
Hello there
$ echo 'UHl0aG9u' | python -m base64 -d
Python

Base32 and Base16

Base32 uses 32 characters: uppercase letters and the digits 2 to 7. It is less compact than Base64, but it is case-insensitive and avoids characters that are easy to confuse, so people can read and type it. Base16 is hexadecimal.

base32_16.py
import base64

data = b'Python'

print(base64.b32encode(data))
print(base64.b32encode(data, padded=False))
print(base64.b32hexencode(data))
print(base64.b16encode(data))
print(data.hex().upper())

print(base64.b32decode('kb4xi2dpny', casefold=True, padded=False))
print(base64.b32decode('KB4X12DPNY', map01=b'I', padded=False))
print(base64.b16decode('507974686f6e', casefold=True))

b32hexencode uses the extended hex alphabet 0-9A-V, which keeps the sort order of the data. b16encode gives the same result as bytes.hex in upper case.

print(base64.b32decode('kb4xi2dpny', casefold=True, padded=False))
print(base64.b32decode('KB4X12DPNY', map01=b'I', padded=False))

The decoders are strict by default. casefold=True accepts lower case. The digits 0 and 1 are not in the alphabet; map01 maps them to the letters O and I (or L), which users often mistype. The padded option of the Base32 functions is new in Python 3.15.

$ python base32_16.py
b'KB4XI2DPNY======'
b'KB4XI2DPNY'
b'A1SN8Q3FDO======'
b'507974686F6E'
507974686F6E
b'Python'
b'Python'
b'Python'

TOTP codes

Authenticator apps share their secrets as Base32 strings, usually without padding. We implement the time-based one-time password algorithm (TOTP, RFC 6238).

totp.py
import base64
import hashlib
import hmac
import secrets
import struct
import time

def totp(secret_b32, at=None, digits=6, step=30, digest=hashlib.sha1):
    key = base64.b32decode(secret_b32, casefold=True, padded=False)
    counter = int((time.time() if at is None else at) // step)
    mac = hmac.new(key, struct.pack('>Q', counter), digest).digest()
    offset = mac[-1] & 0x0f
    code = struct.unpack('>I', mac[offset:offset + 4])[0] & 0x7fffffff
    return str(code % 10 ** digits).zfill(digits)

rfc_secret = base64.b32encode(b'12345678901234567890', padded=False).decode()
print(rfc_secret)

for t in (59, 1111111109, 1234567890, 2000000000):
    print(t, totp(rfc_secret, at=t, digits=8))

new_secret = base64.b32encode(secrets.token_bytes(20), padded=False).decode()
print(new_secret, len(new_secret))
print(f'otpauth://totp/Example:jane?secret={new_secret}&issuer=Example')
print(totp(new_secret))

The secret is decoded from Base32. The current Unix time divided by 30 seconds gives a counter, which is signed with HMAC-SHA1.

offset = mac[-1] & 0x0f
code = struct.unpack('>I', mac[offset:offset + 4])[0] & 0x7fffffff
return str(code % 10 ** digits).zfill(digits)

The low four bits of the last byte select four bytes of the HMAC, which are read as a 31-bit integer. The last digits form the code.

for t in (59, 1111111109, 1234567890, 2000000000):
    print(t, totp(rfc_secret, at=t, digits=8))

We check the implementation with the SHA-1 test vectors from the RFC; the codes match. Then we create a new secret with secrets and build a provisioning URI, which authenticator apps read from a QR code.

$ python totp.py
GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
59 94287082
1111111109 07081804
1234567890 89005924
2000000000 69279037
7RPR572BURUO4B5EJO7O33H5MQIRPGZ5 32
otpauth://totp/Example:jane?secret=7RPR572BURUO4B5EJO7O33H5MQIRPGZ5&issuer=Example
809672

Base85, Ascii85, and Z85

The Base85 encodings turn four bytes into five characters, so they are more compact than Base64. The module supports three alphabets.

base85.py
import base64

data = b'Hello, world!'

print(base64.b85encode(data))
print(base64.a85encode(data))
print(base64.a85encode(data, adobe=True))
print(base64.z85encode(data))
print(base64.z85encode(data, pad=True))

print(base64.a85encode(bytes(8)))
print(base64.a85encode(b'    ', foldspaces=True))

print(base64.b85decode(base64.b85encode(data)) == data)

b85encode uses the alphabet of Git binary diffs. a85encode implements Ascii85 from PostScript and PDF; with adobe=True, the output is framed with <~ and ~>. z85encode uses the ZeroMQ alphabet, which is safe in source code strings.

print(base64.z85encode(data, pad=True))

Z85 requires the input length to be a multiple of four. The pad parameter, new for z85encode in Python 3.15, pads the input with zero bytes.

print(base64.a85encode(bytes(8)))
print(base64.a85encode(b'    ', foldspaces=True))

Ascii85 abbreviates a group of four zero bytes as z, and, with foldspaces, a group of four spaces as y.

$ python base85.py
b'NM&qnZ!92pZ*pv8Ap'
b'87cURD_*#TDfTZ)+T'
b'<~87cURD_*#TDfTZ)+T~>'
b'nm=QNz.92Pz/PV8aP'
b'nm=QNz.92Pz/PV8aPIGx'
b'zz'
b'y'
True

Comparing sizes

The encodings differ in how much they enlarge the data.

sizes.py
import base64

data = bytes(range(256)) * 4

encoders = {
    'hex (b16)': base64.b16encode,
    'base32': base64.b32encode,
    'base64': base64.b64encode,
    'base85': base64.b85encode,
    'ascii85': base64.a85encode,
    'z85': lambda b: base64.z85encode(b, pad=True),
}

print(f'{len(data)} bytes of input')
for name, encode in encoders.items():
    n = len(encode(data))
    print(f'{name:<10} {n:>5} chars  {n / len(data):.2f}x')

We encode 1024 bytes with each encoding. Hexadecimal doubles the size, Base32 adds 60%, Base64 about a third, and Base85 a quarter. Base32 includes its padding, and the Base64 overhead comes out slightly over 4/3 because 1024 is not a multiple of three.

$ python sizes.py
1024 bytes of input
hex (b16)   2048 chars  2.00x
base32      1640 chars  1.60x
base64      1368 chars  1.34x
base85      1280 chars  1.25x
ascii85     1280 chars  1.25x
z85         1280 chars  1.25x

Performance

In Python 3.15, the Base64 implementation in binascii was optimized to make better use of CPU pipelining.

bench.py
import base64
import os
import sys
import timeit

data = os.urandom(1_000_000)
encoded = base64.b64encode(data)

enc = min(timeit.repeat(lambda: base64.b64encode(data), number=50, repeat=5)) / 50
dec = min(timeit.repeat(lambda: base64.b64decode(encoded), number=50, repeat=5)) / 50

print(sys.version.split()[0])
print(f'encode: {len(data) / enc / 1e9:.2f} GB/s')
print(f'decode: {len(encoded) / dec / 1e9:.2f} GB/s')

We measure the throughput of encoding and decoding one megabyte of random data and run the script with Python 3.15 and Python 3.14 on the same machine. The script prints the version it runs on. The exact numbers depend on the CPU.

$ python bench.py
3.15.2
encode: 2.12 GB/s
decode: 2.10 GB/s
$ python3.14 bench.py
3.14.0
encode: 0.61 GB/s
decode: 0.79 GB/s

Base64 is not encryption

Base64 hides data from casual eyes, but it provides no security.

not_encryption.py
import base64

credentials = base64.b64encode(b'jane:s3cret').decode()
print(f'Authorization: Basic {credentials}')

print(base64.b64decode(credentials))

HTTP Basic authentication sends the user name and password encoded in Base64. Anyone who sees the header can decode it, which is why Basic authentication must be used only over HTTPS. To protect data, use encryption; to detect changes, use a signature or an HMAC.

$ python not_encryption.py
Authorization: Basic amFuZTpzM2NyZXQ=
b'jane:s3cret'

Source

base64 – Base16, Base32, Base64, Base85 Data Encodings

RFC 4648 – The Base16, Base32, and Base64 Data Encodings

In this article we have encoded and decoded binary data with the Python base64 module.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.