Skip to content

bytearray is the mutable one

Level: 201 · for Python programmers

One line: Python has exactly one built-in mutable binary type, it has no literal because a literal would be a constant, and it exists not for your convenience but because readinto, recv_into and pack_into need a buffer the caller owns and the callee may write into.

A course slide will tell you three true things about bytearray: there is no dedicated literal syntax, you always build one by calling bytearray(), and it is mutable. All three are correct and none of them says why, which is the part that decides whether you ever reach for the type.

Start from the third fact and the other two follow. Mutability is the whole feature: bytes and bytearray hold the same thing — numbers 0–255 — and differ only in whether anyone may change them afterwards. A bytes can be a dict key, shared between threads and handed to a function without a defensive copy, precisely because it cannot change under anyone. A bytearray gives all of that up in exchange for one capability: something else can write into it.

That is also why there is no literal. A literal is compiled into the code object's constant pool and shared by every execution of that line; the interpreter would have to copy it on every evaluation for a mutable literal to mean anything, at which point you have written bytearray(b"...") with extra steps. Python's answer is to make the copy visible — the literal is the bytes, and the constructor is the copy.

Verified output of bytearray_is_mutable_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. THERE IS NO bytearray LITERAL
     b'AB'                        -> b'AB'                  bytes
     bytearray(b'AB')             -> bytearray(b'AB')
     bytearray([65, 66])          -> bytearray(b'AB')
     bytearray('Łódź', 'utf-8')   -> bytearray(b'\xc5\x81\xc3\xb3d\xc5\xba')
     bytearray(4)                 -> bytearray(b'\x00\x00\x00\x00')
     bytearray('Łódź')            TypeError: string argument without an encoding

     Every one of them is a function call. The literal is the bytes;
     a bytearray is something you wrap around one.
     bytearray(4) is the trap -- it is a SIZE, not the byte 4.

2. WHAT MUTABLE MEANS: TWO NAMES, ONE BUFFER
     bytes:      frozen = b'abc'               alias = b'ab'
     bytearray:  buffer = bytearray(b'abc')    alias = bytearray(b'abc')
     alias is the same object: True

     += is not one operation. On bytes it rebinds a name and leaves
     every other name pointing at the old value. On bytearray it edits
     the object all of those names are looking at.

3. THE FIVE THINGS bytes REFUSES
     on bytes:
     data[0] = 65                 TypeError: 'bytes' object does not support item assignment
     del data[0]                  TypeError: 'bytes' object doesn't support item deletion
     data[0:2] = b'XYZ'           TypeError: 'bytes' object does not support item assignment
     data.append(33)              AttributeError: 'bytes' object has no attribute 'append'
     data.extend(b'de')           AttributeError: 'bytes' object has no attribute 'extend'
     on bytearray:
     buf[0] = 65                  -> bytearray(b'Abc')
     del buf[1]                   -> bytearray(b'Ac')
     buf[0:2] = b'XYZ'            -> bytearray(b'XYZ')   len 2 -> 3, slice assignment RESIZES
     buf.append(33); .extend()    -> bytearray(b'XYZ!de')

     One byte on the left of = is an int, never a bytes:
     buf[0] = b'A'                TypeError: 'bytes' object cannot be interpreted as an integer
     buf[0] = 256                 ValueError: byte must be in range(0, 256)
     buf[99] = 1                  IndexError: bytearray index out of range

4. WHAT MUTABILITY COSTS: THE HASH
     bytearray(b'ab') == b'ab'    -> True
     b'ab' works as a dict key    -> True
     hash(bytearray(b'ab'))       TypeError: unhashable type: 'bytearray'

     Equal, and only one of them can be a dict key or a set member.
     Same bargain as tuple against list: a hash has to keep its word,
     and a buffer you can edit cannot make that promise.

5. WHY IT EXISTS: SOMETHING ELSE WRITES INTO IT
     BytesIO(...).readinto(buf)   -> 4 bytes, buf = bytearray(b'WXYZ')
     struct.pack_into('>I', ...)  -> buf = 00 00 03 e8   (1000)
     memoryview(buf)[0:2] = b'ab' -> buf = bytearray(b'ab\x03\xe8')   (no copy)
     memoryview(b'abcd')[0:1]=b'a' TypeError: cannot modify read-only memory

     readinto, recv_into and pack_into all need a buffer the caller owns
     and the callee may write. That is the job bytearray was added for;
     being able to edit it yourself is the means, not the point.

Four things in that run are worth pausing on.

bytearray(4) is a size, not a value — four zero bytes, not the byte 4. That is the same dispatch bytes() does, and making a bytes object takes it apart constructor by constructor; the only thing to add here is that bytearray inherits all four jobs and adds a fifth, the wrapper form bytearray(b"...") that exists because there is no literal to write instead.

+= is not one operation. On bytes it builds a new object and rebinds your name to it; every other name still points at the old value. On bytearray it edits the object in place, so every name holding that buffer sees the change. This is the aliasing bug in miniature: pass a bytearray to a function and it may come back different, which never happens with bytes.

Slice assignment resizes. buf[0:2] = b"XYZ" replaces two bytes with three and the buffer grows. That is not something a fixed-length byte array in Java, C or JavaScript can do, and it is worth knowing that the length you measured a line ago is not a length you still have.

Mutability costs you the hash. bytearray(b"ab") == b"ab" is True, and only one of the two can be a dict key or a set member. It is the same bargain as tuple against list: a hash is a promise about future comparisons, and an object you can edit cannot make it. So the equal-looking key is not merely absent from the dict — looking it up raises.

The same buffer in six languages

Every language that separates text from bytes needs somewhere to build bytes, and they disagree about where mutability is written down: in the type, in the binding, or nowhere at all.

Language Frozen Mutable How you get the mutable one Can it grow?
Python bytes bytearray bytearray(b"abc") — no literal yes — append, extend, slice assignment
Rust &[u8], and b"abc" is a &[u8; 3] Vec<u8>, &mut [u8] b"abc".to_vec(), vec![0u8; n] Vec yes; [u8; N] never ↗
Go string []byte []byte("abc") yes — append, which may reallocate
Swift let [UInt8] var [UInt8], Data Array("abc".utf8) yes — append
JavaScript string Uint8Array new Uint8Array([65, 66]), new TextEncoder().encode(s) no — fixed at construction, unless the ArrayBuffer was declared resizable
C nothing is frozen, and that is the problem char buf[N], unsigned char * char buf[] = "abc" — the [] is what copies only by realloc

Every row was run while writing this page — Python 3.14.7 (and re-run on 3.12.0 and 3.13.11), rustc 1.98.0, go1.25.5, Swift 6.3.3, Node v20.20.2, Apple clang 21.0.0. Java, C# and ABAP are further down and are prose, not runs.

Three patterns fall out of that table.

"No literal" is the norm, not a Python quirk. JavaScript has no Uint8Array literal; Java's {1, 2, 3} is an array initialiser rather than a literal, and is legal only in a declaration; Swift's [1, 2, 3] is an array literal whose mutability comes from the var, not from the brackets. Rust looks like the exception — b"abc" really is a byte-string literal — but its type is &[u8; 3], a shared reference to a constant, and writing through it does not compile: "cannot assign to lit[_], which is behind a & reference". So Rust has the literal and still will not let you edit it, which is Python's design decision arrived at from the other direction. The reason is the same everywhere: a literal lives in the read-only part of the program, and one you could edit would have to be copied first.

C is the case that shows what the rule is protecting. char *p = "abc"; p[0] = 'A'; compiles, links, and dies at runtime — a bus error on the macOS this was written on, and formally undefined behaviour, which means no particular crash is promised and a quiet corruption is a legal outcome too. Clang will diagnose it, but only if you ask: -Wwrite-strings gives the literal its true type, const char[4], so the assignment to p is flagged as discarding a qualifier. Nothing turns that on for you. char buf[] = "abc" is the safe spelling, the difference is two characters, and the [] is doing the work — it copies the literal into a buffer you own. Python's refusal to give you a mutable literal and Rust's refusal to compile the write are two answers to this one bug.

Where mutability is written down is the real difference. Python encodes it in the type — a Python name has no mut keyword, so the only place left to put the information is the object, and that is why the language needs two types where Rust needs one. Rust and Swift encode it in the binding: Vec<u8> is one type and let versus let mut decides what you may do with it, which is why Rust also has &[u8] and &mut [u8] as separate reference types and why interior mutability ↗ is a named exception rather than the default. Go writes it nowhere: []byte is always mutable and string is always frozen, so the conversion []byte(s) is the copy, and forgetting that it copies is Go's version of this lesson. C writes it nowhere and checks nothing.

One smaller difference is worth carrying around, because it decides how a bug reaches you. Writing past the end of a Python bytearray raises IndexError on the spot; the same write to a JavaScript Uint8Array is silently droppeda[3] = 99 on a three-byte array is not an error, and the array is unchanged afterwards. Go and Rust panic, C corrupts whatever was next in memory. Five languages, four different answers to one typo.

The practical translation, if you already think in Rust: bytes is &[u8] or an immutable Vec<u8>, bytearray is Vec<u8> behind a mut binding, and memoryview is &mut [u8] — a view that borrows the buffer instead of copying it. Python's readinto(buf) is read_exact(&mut buf), one word of syntax apart.

If you are coming from ABAP

ABAP has xstring for a variable-length byte string and TYPE x LENGTH n for a fixed-length one, and neither is a mutable object in the sense this page means. ABAP variables have value semantics: assigning one xstring to another copies it, so the "two names, one buffer" surprise in section 2 of the run cannot happen to you by accident. What replaces it is explicit — a field symbol (ASSIGN) or a data reference (REF TO) is how you get a second name for the same storage, and the aliasing hazard moves there. Coming the other way, the thing to unlearn is the assumption that passing a byte buffer into a Python function is safe: it is a reference, and the callee can edit it. (Not machine-checked — CI cannot run ABAP. Verify any specific code-page or offset-write behaviour against your system rather than trusting a page.)

Two more that this library cannot run either. Java splits it the way this page's table would predict: String is frozen, byte[] is mutable but fixed-length, and growth needs ByteArrayOutputStream or a ByteBuffer — the same immutable/mutable pairing Java already teaches on text with String and StringBuilder, which is why the bytes/bytearray split usually needs no explaining to a Java reader. C# matches it (byte[], List<byte>) and adds Span<byte>, which is memoryview with the borrow checked at compile time. Neither was run for this page.

Try it

  1. Predict what bytearray(3), bytearray([3]) and bytearray(b"3") each contain, then check. Three spellings, three different buffers, and only one of them holds the digit.
  2. Write def zero_out(buf): buf[:] = b"\x00" * len(buf) and call it with a bytearray. Then call it with a bytes and read the error. Which of the two signatures did you mean to write?
  3. Build a 1 MB buffer twice — once with data += chunk on a bytes, once with buf.extend(chunk) on a bytearray — and reason about how many bytes each one copies. (Then measure it, and notice that the answer is about copying, not about speed.)
  4. Put b"key" in a dict, then look it up with bytearray(b"key"). The two are ==. What happens, and which of the two facts about bytearray is the cause?

Practice

What did += do to the other name? Five questions, and the first one is the whole page.

frozen = b"abc"
buf = bytearray(b"abc")
alias_frozen, alias_buf = frozen, buf
frozen += b"d"
buf += b"d"
  1. What do alias_frozen and alias_buf hold now? Neither was assigned to.
  2. buf[0:2] = b"XYZ" — what is len(buf) afterwards?
  3. bytearray(b"key") == b"key" is True. So what does {b"key": 1}[bytearray(b"key")] do — and does .get() save you?
  4. What is in bytearray(2), bytearray([2]) and bytearray(b"2")? Three spellings, three different buffers.
  5. Which of the first four answers the question why does this type exist at all?
Answers

Verified output of bytearray_is_mutable_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. THE ALIAS
     frozen        b'abcd'
     alias_frozen  b'abc'               <- unchanged
     buf           bytearray(b'abcd')
     alias_buf     bytearray(b'abcd')   <- changed, and nobody touched it

     += is not one operation. On bytes it BUILDS a new object and
     rebinds your name; every other name still points at the old
     value. On bytearray it EDITS the object all of those names are
     looking at. Same operator, two mechanisms.
     alias_buf is buf   True

2. THE LENGTH YOU MEASURED A LINE AGO
     buf            bytearray(b'abcd')   len 4
     buf[0:2] = b'XYZ'   -> bytearray(b'XYZcd')   len 5

     Two bytes out, three in, and the buffer grew. Slice assignment
     resizes -- which a fixed-length byte array in Java, C or
     JavaScript cannot do, and which means len() is not a property
     of the buffer you can cache.

3. THE LOOKUP THAT RAISES
     bytearray(b'key') == b'key'   True
     table[bytearray(b'key')]      raises TypeError

     Equal, and only one of them can be a dict key. This is not
     'the key is missing' -- it is 'that object cannot be a key at
     all', which is why it raises instead of returning a default.
     A hash is a promise about future comparisons, and a buffer you
     can edit cannot make it. Same bargain as tuple against list.
     .get() does not save you either:
     table.get(bytearray(b'key'))  raises TypeError

4. THE CONSTRUCTOR IT INHERITED
     bytearray(2)       bytearray(b'\x00\x00')   len 2
     bytearray([2])     bytearray(b'\x02')       len 1
     bytearray(b'2')    bytearray(b'2')          len 1

     The same four-job dispatch bytes() has: a number is a SIZE, a
     list is data, and b'2' is the digit -- the byte 0x32, not 2.
     Three spellings, three different buffers, and only one of them
     holds anything you typed.

5. WHICH OF THE FOUR ANSWERS THE 'WHY DOES IT EXIST' QUESTION?
     Section 1. Everything else on this page is a consequence of
     one capability: something that is not you can write into the
     buffer. readinto, recv_into and pack_into need a buffer the
     caller owns and the callee may fill, and that is the job the
     type was added for. Being able to edit it yourself is the
     means, not the point -- and the hash in section 3 is the price.

See also