Plate 72
bytes vs bytearray: Mutate/Copy Lab
Hands-on bytes vs bytearray lab: real ops/s for append/extend/slice/copy and when copy dominates over mutate, benchmarked on Linux localhost for SREs.
Aditya Challa4 min read
Intro — what this post promises
When does bytes immutability force copies that bytearray avoids? This lab times append / extend / join, one-byte mutation, and 4 KiB copy/slice paths on Linux localhost.
Related links:
- array vs list ints localhost lab
- struct.pack vs to_bytes localhost lab
- string concat vs join localhost lab
- copy vs deepcopy localhost lab
- frozenset vs set membership localhost lab
- contextlib vs try/finally localhost lab
- enum vs constants localhost lab
- perf_counter vs time localhost lab
Lab honesty (1 Oct 2026 IST): Python 3.13.5. Affiliates: 0. Note: on this CPython, b[:] is the same object as b (bytes_slice_is_same_object=True) — slice is not a defensive copy for bytes.
Verdict up front: bytearray.append ~36M/s vs bytes + one byte ~8.0M (~4.4×). Chunked b + chunk ~338× behind bytearray extend; b"".join ≈ extend (~1.06×). Mutating one byte in a 64 KB buffer: bytearray index assign ~107× a bytes rebuild-via-concat.
Arms
| Arm | Pattern |
|---|---|
| bytes concat 1B / 64B | b = b + chunk loop |
b"".join | pre-sized immutable build |
| bytearray append / extend | in-place grow |
| bytes rebuild one byte | b[:i]+b"A"+b[i+1:] on 64 KB |
| bytearray index assign | ba[i] = 65 |
| copy / slice / memoryview | 4 KiB buffers |
Lab topology
Script: lab-evidence/63-bytes-vs-bytearray/results/run_lab.py.
Lead table — build / append (p50)
| Arm | ops/s | ns/op |
|---|---|---|
bytearray .append | 35,668,744 | 28.0 |
bytearray .extend(1B) | 28,226,262 | 35.4 |
bytes + 1B | 8,029,517 | 124.5 |
| bytearray extend 64B | 30,601,439 | 32.7 |
b"".join 64B chunks | 28,928,490 | 34.6 |
bytes + 64B | 90,424 | 11059.1 |
Mutate — when copy dominates
| Arm | ops/s | ns/op |
|---|---|---|
| bytearray index assign | 27,161,395 | 36.8 |
| bytes rebuild via concat | 254,905 | 3923.0 |
One-byte edit on 64 KB: rebuild copies ~64 KB each time (~3923 ns) vs in-place assign (~37 ns) — ~107×.
Copy / slice (4 KiB)
| Arm | ops/s | ns/op |
|---|---|---|
bytes slice b[:] | 29,282,019 | 34.2 |
bytes(...) ctor copy | 15,789,195 | 63.3 |
| memoryview(bytes) | 11,729,737 | 85.3 |
| bytearray slice copy | 10,702,868 | 93.4 |
bytearray(...) ctor | 8,830,548 | 113.2 |
b[:] looking “fast” here is partly because it returns the same object — not a copy. Need isolation? Use bytes(b) / bytearray(b) / memoryview deliberately.
Reading it
- Never grow
byteswith+in a loop — chunked concat was ~338× slower than extend; join fixes immutable builds. - bytearray for mutate-in-place protocols — parsers, checksums, ring buffers.
- bytes for sharing / hashing / dict keys — immutable and hashable.
- Copy cost scales with size — the 64 KB rebuild arm is the honesty check.
Pitfalls
b[:]as a defensive copy — may be identity on CPythonbytes.- Quadratic
b = b + chunk— join or bytearray instead. - Sharing a bytearray across threads/tasks without sync — mutation races.
- Converting bytearray→bytes every step — reintroduces copies.
When to pick what
| Need | Prefer |
|---|---|
| Build then freeze | bytearray → bytes(ba) once |
| Many in-place edits | bytearray |
| Hashable buffer / const payload | bytes |
| Immutable build of known parts | b"".join |
| Zero-copy window | memoryview |
Reproduce
Evidence: /workspace/lab-evidence/63-bytes-vs-bytearray/results/.
Closing
Immutability copies; bytearray mutates. On this box append beat one-byte concat ~4.4×, chunked concat trailed extend by ~338×, and a 64 KB one-byte rebuild trailed index assign by ~107×. Use join when you must stay on bytes; use bytearray when the buffer must change.
Lab evidence
What I found running this
Lab 1 Oct 2026 IST. Python 3.13.5. bytearray append vs bytes concat 1B ~4.4x; extend64 vs concat64 ~338x; index assign vs bytes rebuild ~107x; b''.join ≈ bytearray extend. Affiliates: 0. Evidence: lab-evidence/63-bytes-vs-bytearray/.
Related links
Plate 67
itertools.chain vs Flatten Lab
30 Sept 2026
Plate 17
platform vs os.uname Inventory: Localhost Lab
Hands-on platform.platform vs os.uname host inventory lab: real ops/s plus cache notes, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 50
signal vs threading.Event Wakeup: Localhost Lab
Hands-on signal SIGUSR1 vs threading.Event wakeup lab: real p50 latency in microseconds, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026