A store’s order webhook sent me Python bytes to string trouble last week: customer names printed as b'Jos\xc3\xa9' in the receipts.
Wrapping the value in str() looked like the fix, but it only turned the bytes into their printed form, b'...' and all.
Quick answer: call .decode() on the bytes with the encoding they were written in, almost always UTF-8. str(data, "utf-8") does the same thing:
text = data.decode("utf-8") # bytes -> str
text = str(data, "utf-8") # same result
In this tutorial, I’ll convert bytes to strings for every case I’ve hit on client projects, from UnicodeDecodeError to garbled names and streamed data. Outputs come from Python 3.12.5 on Windows.
What does decode() do to Python bytes?
A bytes object holds raw numbers from 0 to 255. Text only exists once you tell Python which encoding maps those numbers to characters. Here’s the order payload from that webhook:
payload = b"Order 1042 for Jos\xc3\xa9 Ram\xc3\xadrez: $89.50"
print(type(payload).__name__, len(payload), "bytes")
text = payload.decode("utf-8")
print(type(text).__name__, len(text), "characters")
print(text)
print(payload.decode()) # UTF-8 is the default
print(str(payload, "utf-8")) # same result, written as str()
Output:
bytes 37 bytes
str 35 characters
Order 1042 for José Ramírez: $89.50
Order 1042 for José Ramírez: $89.50
Order 1042 for José Ramírez: $89.50

That length gap is the whole story: one character can be several bytes. If bytes itself is new to you, start with my guide to the Python bytes() function.
decode() with no argument uses UTF-8, but I still write "utf-8" so the next reader knows it was a decision.
Why does str() on bytes give b’…’ instead of text?
This is the mistake behind the python b to string searches. str(payload) without an encoding returns the repr, the same text you’d see in the REPL:
payload = b"Order 1042 for Jos\xc3\xa9 Ram\xc3\xadrez: $89.50"
wrong = str(payload) # no encoding -> you get the repr
print(wrong)
print("starts with b'?", wrong.startswith("b'"), "| length", len(wrong))
print(f"Receipt: {payload}") # f-strings call str() too
print(f"Receipt: {payload.decode()}") # decode first
Output:
b'Order 1042 for Jos\xc3\xa9 Ram\xc3\xadrez: $89.50'
starts with b'? True | length 52
Receipt: b'Order 1042 for Jos\xc3\xa9 Ram\xc3\xadrez: $89.50'
Receipt: Order 1042 for José Ramírez: $89.50

str() keeps the b' prefix and the \xc3\xa9 escapes; decode() gives the real name.Don’t strip the b' and the quotes off with slicing. That leaves the escape codes behind, so José still reads as Jos\xc3\xa9.
When you’re not sure what type a value is, check it first; I cover that in checking if a variable is a byte string in Python.
Fix UnicodeDecodeError with the right encoding or errors=
You’ll get UnicodeDecodeError when the bytes weren’t written as UTF-8. Older Windows exports often use cp1252, where ë and é are single bytes:
legacy = "Order 1043 for Zoë Carter, Café Luna: $120.00".encode("cp1252")
print(legacy)
try:
legacy.decode("utf-8")
except UnicodeDecodeError as err:
print("UnicodeDecodeError:", err)
print("replace: ", legacy.decode("utf-8", errors="replace"))
print("ignore: ", legacy.decode("utf-8", errors="ignore"))
print("backslash:", legacy.decode("utf-8", errors="backslashreplace"))
print("cp1252: ", legacy.decode("cp1252")) # the codec it was written in
Output:
b'Order 1043 for Zo\xeb Carter, Caf\xe9 Luna: $120.00'
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xeb in position 17: invalid continuation byte
replace: Order 1043 for Zo� Carter, Caf� Luna: $120.00
ignore: Order 1043 for Zo Carter, Caf Luna: $120.00
backslash: Order 1043 for Zo\xeb Carter, Caf\xe9 Luna: $120.00
cp1252: Order 1043 for Zoë Carter, Café Luna: $120.00

0xeb; errors= keeps going, but only the cp1252 codec gives back Zoë and Café.The errors argument decides what happens to bad bytes. replace inserts �, ignore drops them silently, and backslashreplace keeps them visible as escapes, which I prefer in logs.
None of those recover the text, though. The real fix is the right codec. If you need to clean out the odd characters afterward, see removing Unicode characters in Python.
Repair mojibake like José after decoding bytes
The opposite accident doesn’t raise anything. UTF-8 bytes decoded as cp1252 give you mojibake, the scrambled-text look you’ve seen in old emails:
payload = b"Order 1042 for Jos\xc3\xa9 Ram\xc3\xadrez: $89.50"
garbled = payload.decode("cp1252") # UTF-8 bytes read with the wrong codec
print(garbled)
fixed = garbled.encode("cp1252").decode("utf-8") # undo it, then decode right
print(fixed)
Output:
Order 1042 for José RamÃrez: $89.50
Order 1042 for José Ramírez: $89.50
Because cp1252 maps every byte here, the bad decode succeeds and the damage spreads quietly. Encoding the garbled string back with the wrong codec returns the original bytes, and decoding those as UTF-8 repairs it.
Going the other way, from text to bytes, uses encode(), which I explain in the Python string encode() method.
Convert bytearray, memoryview, hex and Base64 data to a string
The same idea works for every bytes-like type. Same order data, different containers:
import base64
import codecs
buffer = bytearray(b"Paid $89.50")
buffer += b" on 2026-03-14"
print(buffer.decode("utf-8"))
view = memoryview(b"Ship to Denver, CO 80202")
print(str(view, "utf-8")) # memoryview has no .decode()
print(bytes([79, 75, 33]).decode("ascii"))
print(codecs.decode(b"Austin, TX", "utf-8"))
print(bytes.fromhex("53686970706564").decode())
print(base64.b64decode("UmVmdW5kOiAkMTIuOTk=").decode())
Output:
Paid $89.50 on 2026-03-14
Ship to Denver, CO 80202
OK!
Austin, TX
Shipped
Refund: $12.99
bytearray has its own decode(). A memoryview doesn’t, so pass it to str() with an encoding. A list of integers becomes text after bytes().
Hex and Base64 are two steps: turn the text into bytes, then decode. More on the mutable type in the Python bytearray() function, and the hex step in converting a hex string to bytes in Python.
How do you decode bytes that arrive in chunks?
Sockets, HTTP streams and big files hand you bytes in pieces. If a piece ends halfway through a multi-byte character, decoding each piece alone breaks:
import codecs
data = "José Ramírez paid $89.50".encode("utf-8")
chunks = [data[:4], data[4:]] # the split lands inside the 2-byte é
print(chunks)
try:
print("".join(c.decode("utf-8") for c in chunks))
except UnicodeDecodeError as err:
print("UnicodeDecodeError:", err.reason)
print(b"".join(chunks).decode("utf-8")) # join first, then decode
dec = codecs.getincrementaldecoder("utf-8")()
print("".join(dec.decode(c) for c in chunks) + dec.decode(b"", final=True))
Output:
[b'Jos\xc3', b'\xa9 Ram\xc3\xadrez paid $89.50']
UnicodeDecodeError: unexpected end of data
José Ramírez paid $89.50
José Ramírez paid $89.50

Joining is fine for small payloads. For a long stream, the incremental decoder holds back the half character until the rest arrives, so memory stays flat.
With files, let open() handle this by opening in text mode with encoding="utf-8". Use binary mode only when you need raw bytes, as in reading a binary file in Python.
Get strings from subprocess output and BytesIO
subprocess.run() returns bytes by default, which is where a lot of b'...' output comes from. BytesIO is the other common source:
import io
import subprocess
import sys
cmd = [sys.executable, "-c", "print('Denver, CO 80202')"]
raw = subprocess.run(cmd, capture_output=True)
print(type(raw.stdout).__name__, raw.stdout)
print(raw.stdout.decode().strip())
txt = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
print(type(txt.stdout).__name__, txt.stdout.strip())
buf = io.BytesIO("Invoice total: $1,249.99".encode("utf-8"))
print(buf.getvalue().decode("utf-8"))
Output:
bytes b'Denver, CO 80202\r\n'
Denver, CO 80202
str Denver, CO 80202
Invoice total: $1,249.99
Pass text=True with an explicit encoding and you’ll get a str straight away. For BytesIO, call getvalue().decode().
I also call .strip() on command output, because the trailing newline from the child process is part of the bytes.
Common errors and how to fix them
Two errors come up whenever bytes and strings meet in the same code:
text = "Paid $89.50"
try:
text.decode("utf-8")
except AttributeError as err:
print("AttributeError:", err)
try:
print(b"Total: " + "$89.50")
except TypeError as err:
print("TypeError:", err)
def as_text(value):
return value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else value
print(as_text(b"Total: ") + as_text("$89.50"))
Output:
AttributeError: 'str' object has no attribute 'decode'
TypeError: can't concat str to bytes
Total: $89.50
str has no decode() in Python 3, so that error means the value was already text. The TypeError means you mixed types; my post on fixing can’t concat str to bytes covers it in depth.
A small helper like as_text() keeps code that receives either type from failing.
Bytes to string methods compared
| Method | Works on | When I use it |
|---|---|---|
b.decode("utf-8") | bytes, bytearray | Default choice |
str(b, "utf-8") | bytes, bytearray, memoryview | memoryview, or when you prefer str() |
codecs.decode(b, "utf-8") | bytes-like | Codec name stored in a variable or config |
decode(errors="replace") | bytes, bytearray | Logs and previews, never for saved data |