Making a bytes object¶
Level: 101 → 201 · for Python programmers
One line: bytes() is four constructors sharing one name, and nothing in the call says which one you asked for — the type of the argument decides, which is why bytes(5) and bytes([5]) hand back different amounts of different things.
bytes(...) looks like int(...) or str(...): a type name used as a conversion. It is not one. str(5) is '5' and int('5') is 5, but bytes(5) is five zero bytes — not the digit, and not one byte holding five. The call is a small dispatch table with the argument's type as the key:
| What you pass | What you get | The job it is really doing |
|---|---|---|
| nothing | b'' |
an empty literal |
a str and an encoding |
those characters, encoded | .encode(), written backwards |
an int |
that many zero bytes | allocating a buffer |
| an iterable of ints | one byte per element | building a value from data |
something with __bytes__, or a buffer |
whatever that object says | a copy, or a conversion the object defines |
Four of those five have nothing to do with each other. Only one is a conversion in any ordinary sense; one is an allocation, and one is a literal. They share a name for a reason worth one line of history. Python 2 had no bytes type to design: the name was a synonym for str ↗, added in 2.6 so that isinstance(x, bytes) would mean something to the 2to3 converter. Python's own release notes give the before-and-after in one line — bytes([65, 66, 67]) is three bytes in Python 3 and a twelve-character string in Python 2, because there it was str([65, 66, 67]). The name outlived that change of meaning and the allocator was hung on it afterwards, which is where the trap below comes from.
Verified output of making_a_bytes_object_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. ONE NAME, FOUR JOBS
bytes() -> b''
bytes(5) -> b'\x00\x00\x00\x00\x00'
bytes([5]) -> b'\x05'
bytes('hi', 'utf-8') -> b'hi'
Nothing in the call says which job you wanted. The TYPE of the
argument chose it, and the four answers have nothing in common:
empty, a buffer, one datum, and an encoding.
2. THE TWO THAT LOOK ALIKE
bytes(5) -> b'\x00\x00\x00\x00\x00'
bytes([5]) -> b'\x05'
str(5) -> '5'
bytes(True) -> b'\x00'
bytes(-1) !! ValueError: negative count
bytes(5) is FIVE zero bytes -- a buffer of that size. bytes([5])
is ONE byte holding 5 -- a value. str(5) is the digit, which is
what a reader expects bytes(5) to be. And True is an int, so
bytes(True) is one zero byte and never b'\x01'.
3. PYTHON WILL NOT GUESS AN ENCODING
bytes('Łódź') !! TypeError: string argument without an encoding
bytes('Łódź', 'utf-8') -> b'\xc5\x81\xc3\xb3d\xc5\xba'
bytes('Łódź', 'latin-1') !! UnicodeEncodeError: 'latin-1' codec can't encode character '\u0141' in position 0: ordinal not in range(256)
bytes('Łódź', 'ascii', 'replace') -> b'??d?'
'Łódź'.encode() -> b'\xc5\x81\xc3\xb3d\xc5\xba'
The two-argument form is .encode() written backwards, and the
third argument is errors=. The bare call is the only one of the
four jobs Python refuses outright: there is no default encoding.
4. THE ITERABLE FORM CHECKS EVERY VALUE
bytes([72, 105]) -> b'Hi'
bytes(range(5)) -> b'\x00\x01\x02\x03\x04'
bytes(x * 2 for x in [1, 2]) -> b'\x02\x04'
bytes([256]) !! ValueError: bytes must be in range(0, 256)
bytes([-1]) !! ValueError: bytes must be in range(0, 256)
bytes([1.0]) !! TypeError: 'float' object cannot be interpreted as an integer
Every element has to be an int in range(0, 256). Nothing is
truncated, rounded or wrapped -- the call fails instead.
5. HOW THE JOB IS CHOSEN
bytes(Bytesish()) __bytes__ -> b'__bytes__ ran'
bytes(Indexish()) __index__ -> b'\x00\x00\x00'
bytes(Both()) both -> b'__bytes__ won'
bytes(3.0) neither !! TypeError: cannot convert 'float' object to bytes
The ladder: an encoding argument means 'encode this str'; then
__bytes__ if the object has one; then __index__ for the zero-fill;
then the buffer protocol; then any iterable of ints. A float has
none of those, so it is not 'rounded' -- it is refused.
6. THE OTHER DOORS INTO bytes
bytes.fromhex('c5 81') -> b'\xc5\x81'
(321).to_bytes(2, 'big') -> b'\x01A'
bytes(bytearray(b'ab')) -> b'ab'
bytes(memoryview(b'mv')) -> b'mv'
Three of these say in their names what bytes() says only in the
type of its argument. That is the whole argument for preferring
them: .encode() cannot be mistaken for an allocation.
Three things in that run are worth stopping on.
bytes(5) and bytes([5]) differ in every respect. One is a five-byte zero-filled buffer; the other is one byte holding the number 5. There is no reading of the call that makes those two the same question, and the only thing distinguishing them is a pair of brackets. This is the form the trap takes in real code: a length that arrives as a number when you expected a list, or a list when you expected a length, produces a bytes of the wrong size with no error anywhere.
bytes(True) is one zero byte. A bool is an int, so True takes the allocator branch and means "one byte, please" — never b'\x01'. The value you passed is used as a count and then discarded, which is the same surprise as the line above, wearing a different type.
The iterable form refuses out-of-range values rather than fixing them. bytes([256]) raises ValueError: bytes must be in range(0, 256); bytes([-1]) raises the same; bytes([1.0]) raises TypeError rather than rounding. Nothing is truncated and nothing wraps. Hold on to that, because it is where every other language in the next section parts company with Python.
One aside from section 6 that catches people separately: (321).to_bytes(2, "big") displays as b'\x01A'. The second byte is 0x41, and repr shows a printable ASCII byte as its character. That A is a display choice about a number, not a letter that got in — see str is not bytes.
The same jobs in other languages¶
Every language needs the same three things: a zeroed buffer of a given size, the bytes of a string, and a byte sequence built from numbers. Python is unusual in spelling all three with one name.
| Language | n zero bytes | The bytes of a string | From a list of numbers |
|---|---|---|---|
| Python | bytes(5) |
s.encode("utf-8") — or bytes(s, "utf-8") |
bytes([1, 2, 3]) |
| Rust | vec![0u8; 5] |
s.as_bytes() ↗ — no encoding to name |
vec![1u8, 2, 3] |
| Go | make([]byte, 5) |
[]byte(s) — no encoding to name |
[]byte{1, 2, 3} |
| C | unsigned char b[5] = {0} |
it already is bytes — char * |
unsigned char b[] = {1, 2, 3} |
| JavaScript (Node) | Buffer.alloc(5) |
Buffer.from(s, "utf8") |
Buffer.from([1, 2, 3]) |
| JavaScript (browser) | new Uint8Array(5) |
new TextEncoder().encode(s) — UTF-8, always |
Uint8Array.from([1, 2, 3]) |
| Swift | [UInt8](repeating: 0, count: 5) |
Array(s.utf8) |
[1, 2, 3] as [UInt8] |
| Java | new byte[5] |
s.getBytes(UTF_8) |
new byte[]{1, 2, 3} |
(Measured 2026-09-07 on macOS 26 — Python 3.14.7, rustc 1.98.0, go 1.25.5, Apple clang 21.0.0, Node 20.20.2, Swift 6.3.3. The Java and ABAP rows were not run here and say so where they make a claim.)
Read down the first column: seven languages, seven different spellings, and every one of them contains the word alloc, make, repeating or new — a verb that says allocate. Python's says bytes. That is the whole of the trap, and it is the reason bytes(5) reads as a conversion to everybody the first time.
Two languages have no encoding argument at all, and it is worth knowing why before you envy them. A Rust String and a Go string are already UTF-8 bytes in memory, so s.as_bytes() and []byte(s) are a borrow and a copy — free, and unable to fail. Python's str is a sequence of code points with no fixed representation you can hand out, so the conversion has to name an encoding, and naming one admits it can fail. The cost is symmetric: Rust and Go make you handle the other direction instead, because arbitrary bytes might not be UTF-8, and this repo's crosswalk has that row next to this one.
Where a language does have the argument, look at what it does when you leave it out. Python raises TypeError: string argument without an encoding — the only entry in the whole dispatch table it refuses outright. Java's s.getBytes() used the platform default charset, so the same program produced different bytes on two machines, and it took until Java 18 and JEP 400 ↗ to make UTF-8 the default (not run here — that is the JEP's own account). Node's Buffer.from(s) defaults to UTF-8, which is fine, but Buffer.from(s, "latin1") is not: on "Łódź" it returns 41 f3 64 7a, having quietly turned Ł into A by keeping the low byte of the code unit. Python's "Łódź".encode("latin-1") raises UnicodeEncodeError instead. Same request, same wrong encoding, and one of them tells you.
What an out-of-range number does¶
This is the sharpest split, and Python is alone on its side of it.
| Language | 256 written as a byte |
A runtime value of 300 |
|---|---|---|
| Python | ValueError: bytes must be in range(0, 256) |
same — it raises |
| Rust | does not compile: "literal out of range for u8" |
300 as u8 is 44, silently; u8::try_from(300) gives Err |
| Go | does not compile: "cannot use 256 … as byte value … (overflows)" | byte(n) is 44, silently |
| Swift | does not compile: "integer literal '256' overflows when stored into 'UInt8'" | UInt8(300) traps — the process dies; UInt8(exactly:) gives nil |
| C | compiles, with a warning; the byte is 0 |
(unsigned char)300 is 44, silently |
| JavaScript | Buffer.from([256]) is 00; [-1] is ff |
same — silent mod 256 |
Four distinct policies: refuse (Python), refuse the literal and truncate the variable (Rust, Go, C), refuse the literal and crash on the variable (Swift), and never mention it (JavaScript). The compiled languages look strictest and are not, quite — they catch what you typed and let through what you computed, which is the direction most real bugs come from. Rust is the one that offers both, and the choice is in the name: as truncates, try_from returns a Result.
Node had Python's constructor, and took it out¶
new Buffer(x) dispatched on argument type exactly the way bytes(x) does: a number allocated, a string encoded, an array copied. It is deprecated ↗ — Node's own reason is "API usability issues that can lead to accidental security issues" — and it was split into three named calls, Buffer.alloc, Buffer.allocUnsafe and Buffer.from. Today Buffer.from(5) is a TypeError that lists the types it will accept.
Two things made it dangerous, and only one of them is Python's problem. The replacement pair is named alloc and allocUnsafe because the number branch handed back uninitialized memory, so a length that arrived from a request body could return whatever had been in the heap; Python's zero-fills, so that half does not apply here. The other half does: when a value can arrive as a number or as a string, the argument's type silently selects the behaviour, and the value that decides is the one you trust least. bytes(user_supplied) has the same shape, and its failure is quieter — not a leak, just a buffer of the wrong size that nothing complains about.
The lesson is not that bytes() is dangerous — it is that the named door is better. s.encode("utf-8"), bytes.fromhex(...), n.to_bytes(2, "big") and bytearray(n) each say in the call what bytes() says only in the type of its argument.
If you are coming from ABAP¶
xstring is the type, and the split is one you already have: cl_abap_conv_codepage=>create_out( ) for text → bytes is the .encode() half, and there is no single constructor that also allocates, so ABAP never had this overload to trip over. What does transfer is the encoding argument. An ABAP conversion that does not name a code page inherits the system's, which is the Java-before-18 situation in the paragraph above — the program is correct on the system where it was written and elsewhere is a guess. Python removes that failure mode by having no default at all: the call that would have inherited one is the call that raises. (Not machine-checked — CI cannot run ABAP. Verify any specific code-page number against the system rather than trusting a page.)
Try it¶
- Predict all four before running:
bytes(2),bytes([2]),bytes("2", "ascii"),bytes.fromhex("02"). Which two are equal, and which one is longer than the others? - Write
bytes(n)wherencame fromlen(something)— then pass it a list by mistake. How far does the program get before anything looks wrong? bytes(range(256))works andbytes(range(257))does not. Where exactly does the second one fail, and what does that tell you about when the range check happens?bytes({3, 1, 2})is accepted. Should it be? (It builds from aset, so the byte order is the set's iteration order, not yours.)
Practice¶
Eight calls into a constructor with four jobs. For each one, write down the length of the result and the bytes in it — or the name of the exception it raises.
bytes(3)bytes([3])bytes("3", "ascii")bytes.fromhex("03")bytes(True)bytes("3")bytes([300])bytes(3.0)
Then: exactly two of the eight are equal to each other. Which two? And the follow-up that matters more than the answer — for each of the five calls that succeeded, which of the four jobs did you get, and what in the call told you?
Answers
Verified output of making_a_bytes_object_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
call len result which job
-----------------------------------------------------------------------------
bytes(3) 3 b'\x00\x00\x00' the allocator: a SIZE, zero-filled
bytes([3]) 1 b'\x03' one datum: the number 3, as a byte
bytes('3', 'ascii') 1 b'3' encode(), written backwards
bytes.fromhex('03') 1 b'\x03' the named door to the same thing as line 2
bytes(True) 1 b'\x00' bool is an int, so this is the allocator again
bytes('3') -- TypeError no default encoding exists, so it refuses
bytes([300]) -- ValueError range(0, 256), and nothing is truncated
bytes(3.0) -- TypeError a float has no __bytes__, no __index__, no buffer
WHICH TWO ARE EQUAL?
Lines 2 and 4. bytes([3]) and bytes.fromhex('03') are both the
single byte 0x03; everything else on the list is a different
length, a different value, or an exception.
bytes([3]) == bytes.fromhex('03') True
bytes(3) == bytes([3]) False
bytes(True) == bytes([1]) False
bytes('3', 'ascii') == bytes([3]) False
The third line is the one worth staring at. bytes(True) is not
b'\x01' -- True is an int, the int branch is the allocator, and
the value you passed was used as a COUNT and then thrown away.
THE ONE-LINE RULE
bytes(x) reads the TYPE of x, not the value. A number means
'this many zero bytes'. Everything named -- .encode(),
.fromhex(), .to_bytes() -- says in the call what bytes() says
only in the type of its argument, which is the whole argument
for preferring them.
'Za'.encode('utf-8') b'Za'
bytes.fromhex('5a61') b'Za'
(23137).to_bytes(2, 'big') b'Za'
Three spellings of the same two bytes, and none of them could
be mistaken for an allocation.
See also¶
stris notbytes— the type boundary this constructor sits on- Encode and decode — the two-argument form, done the idiomatic way, and what
errors=throws away - The crosswalk — the same idea in Rust, C and ABAP
- A byte is eight bits ↗ — what the numbers 0–255 are, before any language names them
- Meet the byte ↗ —
u8in Rust, where the range check is the type - Printing bytes ↗ — how Rust shows the value once it is built:
{:?}gives the numbers,escape_ascii()gives whatb'…'gives here