What did you do?
Loaded an IM (.im) file containing multiple Comment: header lines — which ImImagePlugin legitimately stores as a list in .info["Comment"] (its own code comment says "COMMENT tags are combined into a list of strings") — called .copy() on the resulting Image, and appended to the copy's info["Comment"] list.
Minimal, self-contained repro (builds the .im file at runtime):
from PIL import Image
import io
im = Image.new("L", (4, 4), 128)
buf = io.BytesIO()
im.save(buf, format="IM")
raw = buf.getvalue()
header = (b'Image type: Greyscale image\r\n'
b'Image size (x*y): 4*4\r\n'
b'File size (no of images): 1\r\n'
b'Comment: first comment\r\n'
b'Comment: second comment\r\n')
new_header = header + b"\x00" * (511 - len(header)) + b"\x1a"
with open("real_test.im", "wb") as f:
f.write(new_header + raw[512:])
loaded = Image.open("real_test.im")
copy1 = loaded.copy()
print(loaded.info["Comment"] is copy1.info["Comment"]) # True
copy1.info["Comment"].append("added only to the copy")
print(loaded.info["Comment"])
What did you expect to happen?
Image.copy()'s docstring says: "Use this method if you wish to paste things into an image, but still retain the original." I expected mutating data obtained only through the copy to leave the original untouched, i.e. loaded.info["Comment"] should stay ['first comment', 'second comment'].
What actually happened?
loaded.info["Comment"] also gained the appended entry:
['first comment', 'second comment', 'added only to the copy']
The same aliasing applies to IptcImagePlugin.py, which also stores self.info[tag] = [self.info[tag], tagdata] for a repeated IPTC tag.
Root cause
Image._new() in src/PIL/Image.py:
new.info = self.info.copy()
This is a shallow copy of the dict container only — any mutable value inside (list, dict, etc.) remains a shared reference between the original and the copy. Most .info values (ints, str, bytes, tuples) are immutable, so this is invisible in the common case, but ImImagePlugin and IptcImagePlugin both legitimately place list objects into .info, making the aliasing externally observable and silently breaking copy/original independence for those formats.
Notably, PR #6294 ("Separate multiple GIF comment blocks with newlines") already fixed this exact hazard for GIF's own multi-comment handling by switching to += on immutable bytes — but IM and IPTC were never updated to match, leaving them exposed.
Suggested fix
new.info = copy.deepcopy(self.info) in Image._new(), or at minimum have ImImagePlugin/IptcImagePlugin store an already-copied list before mutating (e.g. self.info[k] = list(self.info[k]) + [v]) so .info never contains an object shared between the original and a .copy()'d Image.
Versions
Pillow 12.3.0 (current PyPI release), Python 3.12.10, Windows 11. Confirmed the same code (Image._new, ImImagePlugin._open, IptcImagePlugin) is unchanged on main as of 2026-09-04.
Found via a property-based audit (copy/clone independence) run across several widely-used Python libraries; repro was written and independently re-run twice (matching output) before filing.
What did you do?
Loaded an IM (.im) file containing multiple
Comment:header lines — whichImImagePluginlegitimately stores as alistin.info["Comment"](its own code comment says "COMMENT tags are combined into a list of strings") — called.copy()on the resulting Image, and appended to the copy'sinfo["Comment"]list.Minimal, self-contained repro (builds the
.imfile at runtime):What did you expect to happen?
Image.copy()'s docstring says: "Use this method if you wish to paste things into an image, but still retain the original." I expected mutating data obtained only through the copy to leave the original untouched, i.e.loaded.info["Comment"]should stay['first comment', 'second comment'].What actually happened?
loaded.info["Comment"]also gained the appended entry:The same aliasing applies to
IptcImagePlugin.py, which also storesself.info[tag] = [self.info[tag], tagdata]for a repeated IPTC tag.Root cause
Image._new()insrc/PIL/Image.py:This is a shallow copy of the dict container only — any mutable value inside (list, dict, etc.) remains a shared reference between the original and the copy. Most
.infovalues (ints, str, bytes, tuples) are immutable, so this is invisible in the common case, butImImagePluginandIptcImagePluginboth legitimately placelistobjects into.info, making the aliasing externally observable and silently breaking copy/original independence for those formats.Notably, PR #6294 ("Separate multiple GIF comment blocks with newlines") already fixed this exact hazard for GIF's own multi-comment handling by switching to
+=on immutablebytes— but IM and IPTC were never updated to match, leaving them exposed.Suggested fix
new.info = copy.deepcopy(self.info)inImage._new(), or at minimum haveImImagePlugin/IptcImagePluginstore an already-copied list before mutating (e.g.self.info[k] = list(self.info[k]) + [v]) so.infonever contains an object shared between the original and a.copy()'d Image.Versions
Pillow 12.3.0 (current PyPI release), Python 3.12.10, Windows 11. Confirmed the same code (
Image._new,ImImagePlugin._open,IptcImagePlugin) is unchanged onmainas of 2026-09-04.Found via a property-based audit (copy/clone independence) run across several widely-used Python libraries; repro was written and independently re-run twice (matching output) before filing.