Skip to content

Can't save all extensions to BytesIO #9428

Description

@CTimmerman

Image

What did you do?

Try to automate testing all supported save formats.

What did you expect to happen?

Use the special needs of the specific extensions like BW of SGI only accepting mode L. (Why don't the plugins use Pillow to convert the mode themselves?)

What actually happened?

python -m doctest main.py
**********************************************************************
File "d:\code\image viewer\tk_image_viewer\main.py", line 1157, in main.test_save
Failed example:
    test_save()
Exception raised:
    Traceback (most recent call last):
      File "C:\Users\C\AppData\Local\Python\pythoncore-3.13-64\Lib\doctest.py", line 1398, in __run
        exec(compile(example.source, filename, "single",
        ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                     compileflags, True), test.globs)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "<doctest main.test_save[0]>", line 1, in <module>
        test_save()
        ~~~~~~~~~^^
      File "d:\code\image viewer\tk_image_viewer\main.py", line 1163, in test_save
        im.save(out, format=ext)
        ~~~~~~~^^^^^^^^^^^^^^^^^
      File "D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL\Image.py", line 2575, in save
        save_handler = SAVE[format.upper()]
                       ~~~~^^^^^^^^^^^^^^^^
    KeyError: '.APNG'
**********************************************************************
1 item had failures:
   1 of   1 in main.test_save
***Test Failed*** 1 failure.

What are your OS, Python and Pillow versions?

  • OS: Windows 11
  • Python: Python 3.13 (in active venv)
  • Pillow: 12.1.0
Please paste here the output of running:

python3 -m PIL.report 
--------------------------------------------------------------------
Pillow 12.1.0
Python 3.14.2 (tags/v3.14.2:df79316, Dec  5 2025, 17:18:21) [MSC v.1944 64 bit (AMD64)]
--------------------------------------------------------------------
Python executable is C:\Users\C\AppData\Local\Python\pythoncore-3.14-64\python.exe
System Python files loaded from C:\Users\C\AppData\Local\Python\pythoncore-3.14-64
--------------------------------------------------------------------
Python Pillow modules loaded from C:\Users\C\AppData\Local\Python\pythoncore-3.14-64\Lib\site-packages\PIL
Binary Pillow modules loaded from C:\Users\C\AppData\Local\Python\pythoncore-3.14-64\Lib\site-packages\PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 12.1.0
--- TKINTER support ok, loaded 8.6
--- FREETYPE2 support ok, loaded 2.14.1
--- LITTLECMS2 support ok, loaded 2.17
--- WEBP support ok, loaded 1.6.0
--- AVIF support ok, loaded 1.3.0
--- JPEG support ok, compiled for libjpeg-turbo 3.1.3
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4
--- ZLIB (PNG/ZIP) support ok, loaded 1.3.1.zlib-ng, compiled for zlib-ng 2.3.2
--- LIBTIFF support ok, loaded 4.7.1
*** RAQM (Bidirectional Text) support not installed
*** LIBIMAGEQUANT (Quantization method) support not installed
*** XCB (X protocol) support not installed
--------------------------------------------------------------------

python -m PIL.report 
--------------------------------------------------------------------
Pillow 12.1.0
Python 3.13.12 (tags/v3.13.12:1cbe481, Feb  3 2026, 18:22:25) [MSC v.1944 64 bit (AMD64)]
--------------------------------------------------------------------
Python executable is D:\code\image viewer\tk_image_viewer\venv\Scripts\python.exe
Environment Python files loaded from D:\code\image viewer\tk_image_viewer\venv
System Python files loaded from C:\Users\C\AppData\Local\Python\pythoncore-3.13-64
--------------------------------------------------------------------
Python Pillow modules loaded from D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL
Binary Pillow modules loaded from D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL
--------------------------------------------------------------------
--- PIL CORE support ok, compiled for 12.1.0
--- TKINTER support ok, loaded 8.6
--- FREETYPE2 support ok, loaded 2.14.1
--- LITTLECMS2 support ok, loaded 2.17
--- WEBP support ok, loaded 1.6.0
--- AVIF support ok, loaded 1.3.0
--- JPEG support ok, compiled for libjpeg-turbo 3.1.3
--- OPENJPEG (JPEG2000) support ok, loaded 2.5.4
--- ZLIB (PNG/ZIP) support ok, loaded 1.3.1.zlib-ng, compiled for zlib-ng 2.3.2
--- LIBTIFF support ok, loaded 4.7.1
--- RAQM (Bidirectional Text) support ok, loaded 0.10.3, fribidi 1.0.13, harfbuzz 12.3.0
*** LIBIMAGEQUANT (Quantization method) support not installed
*** XCB (X protocol) support not installed
--------------------------------------------------------------------

#TIL venv doesn't override "python3" as command. Sad.
from io import BytesIO
from PIL import Image

im = Image.open("eye.gif")
out = BytesIO()
for ext in ("APNG", "BW"):
        im.save(out, ext)

Activity

  1. radarhere commented on Feb 11, 2026

    @radarhere
    Member

    In your error trace, you're showing im.save(out, format=ext) and KeyError: '.APNG'. The format argument from save() is not intended to take an extension, but a format. In this case, it is "PNG"

    If I adjust your code to im.save(out, "PNG"), it works. Pillow will automatically give you an APNG image if there is more than one frame.

    Why don't the plugins use Pillow to convert the mode themselves?

    You may be interested in #2663, an open issue with a suggestion for how to make this easier.

  2. CTimmerman commented on Feb 11, 2026

    @CTimmerman
    ContributorAuthor

    I tried "APNG" before, which failed. As Pillow usually parses the extension anyway, I figured it might work as the format argument as well. Alas, even after providing file names, it appears that Pillow needs a real file:

    from PIL import Image
    from pyfakefs.fake_filesystem_unittest import patchfs
    
    im = Image.open("eye.gif")
    
    
    def set_supported_files():
        """Set supported files"""
        global SUPPORTED_EXTENSIONS, SUPPORTED_FILES_READ, SUPPORTED_FILES_WRITE
        exts = Image.registered_extensions()
        exts[".eml"] = "MHTML"
        exts[".mht"] = "MHTML"
        exts[".mhtml"] = "MHTML"
        exts[".svg"] = "SVG"
        exts[".svgz"] = "SVG"
        exts[".tar"] = "TAR"
        exts[".tar.bz2"] = "TAR"
        exts[".tbz2"] = "TAR"
        exts[".tar.gz"] = "TAR"
        exts[".tgz"] = "TAR"
        exts[".tar.xz"] = "TAR"
        exts[".txz"] = "TAR"
        exts[".tar.zst"] = "TAR"
        exts[".tar.zstd"] = "TAR"
        exts[".zip"] = "ZIP"
        added_exts = [
            "EML",
            "MHT",
            "MHTML",
            "SVG",
            "SVGZ",
            "TAR",
            "TAR.BZ2",
            "TAR.GZ",
            "TAR.XZ",
            "TAR.ZST",
            "TAR.ZSTD",
            "TBZ2",
            "TGZ",
            "TXZ",
            "ZIP",
        ]
    
        SUPPORTED_EXTENSIONS = sorted(
            [k[1:].upper() for k, v in exts.items() if v in Image.OPEN] + added_exts
        )
        type_exts = {}
        for k, v in exts.items():
            type_exts.setdefault(v, []).append(k)
    
        SUPPORTED_FILES_READ = [
            (
                "All supported files",
                " ".join(
                    sorted(list(k for k, v in exts.items() if v in Image.OPEN) + added_exts)
                ),
            ),
            ("All files", "*"),
            (
                "Archives",
                ".eml .mht .mhtml .tar .tar.bz2 .tbz2 .tar.gz .tgz .tar.xz .txz .tar.zst .tar.zstd .zip",
            ),
            *sorted(
                (k, v) for k, v in type_exts.items() if k in Image.OPEN or k in added_exts
            ),
        ]
        SUPPORTED_FILES_WRITE = [
            (
                "All supported files",
                " ".join(sorted(list(k for k, v in exts.items() if v in Image.SAVE))),
            ),
            *sorted((k, v) for k, v in type_exts.items() if k in Image.SAVE),
        ]
        print(
            f"Reads {len(SUPPORTED_EXTENSIONS)} formats:\n{", ".join(SUPPORTED_EXTENSIONS)}"
        )
        saves = sorted(k[1:].upper() for k, v in exts.items() if v in Image.SAVE)
        print(
            f"Writes {len(saves)} formats:\n{", ".join(saves)}",
        )
        print(
            f"Saves all frames:\n{", ".join(
                sorted(k[1:].upper() for k, v in exts.items() if v in Image.SAVE_ALL)
            )}"
        )
    
    
    @patchfs
    def test_save(fake_fs):
        """
        >>> test_save()
        """
        set_supported_files()
        print(Image.SAVE)
        exts = ("APNG", "BW")
        list(v for v in exts if v in Image.SAVE)
        for ext in SUPPORTED_FILES_WRITE[0][1].split(" "):
            fname = f"/foo/bar{ext}"
            print(f"Testing {fname}")
            fake_fs.create_file(fname)
            im.save(fname)
        print("Done.")
    
    
    if __name__ == "__main__":
        test_save()
    
    
    
    (venv)  1:26:10 d:\code\image viewer\tk_image_viewer>python test.py
    Reads 82 formats:
    APNG, AVIF, AVIFS, BLP, BMP, BUFR, BW, CUR, DCX, DDS, DIB, EMF, EML, EPS, FIT, FITS, FLC, FLI, FTC, FTU, GBR, GIF, GRIB, H5, HDF, ICB, ICNS, ICO, IIM, IM, J2C, J2K, JFIF, JP2, JPC, JPE, JPEG, JPF, JPG, JPX, MHT, MHTML, MPEG, MPG, MSP, PBM, PCD, PCX, PFM, PGM, PNG, PNM, PPM, PS, PSD, PXR, QOI, RAS, RGB, RGBA, SGI, SVG, SVGZ, TAR, TAR.BZ2, TAR.GZ, TAR.XZ, TAR.ZST, TAR.ZSTD, TBZ2, TGA, TGZ, TIF, TIFF, TXZ, VDA, VST, WEBP, WMF, XBM, XPM, ZIP
    Writes 53 formats:
    APNG, AVIF, AVIFS, BLP, BMP, BUFR, BW, DDS, DIB, EMF, EPS, GIF, GRIB, H5, HDF, ICB, ICNS, ICO, IM, J2C, J2K, JFIF, JP2, JPC, JPE, JPEG, JPF, JPG, JPX, MPO, MSP, PALM, PBM, PCX, PDF, PFM, PGM, PNG, PNM, PPM, PS, QOI, RGB, RGBA, SGI, TGA, TIF, TIFF, VDA, VST, WEBP, WMF, XBM
    Saves all frames:
    APNG, AVIF, AVIFS, GIF, MPO, PDF, PNG, TIF, TIFF, WEBP
    {'BMP': <function _save at 0x00000174B736E340>, 'DIB': <function _dib_save at 0x00000174B736DEE0>, 'GIF': <function _save at 0x00000174B7397B00>, 'JPEG': <function _save at 0x00000174B73A51C0>, 'PPM': <function _save at 0x00000174B73A56C0>, 'PNG': <function _save at 0x00000174B73F71A0>, 'AVIF': <function _save at 0x00000174B7424360>, 'BLP': <function _save at 0x00000174B7424AE0>, 'BUFR': <function _save at 0x00000174B7425760>, 'PCX': <function _save at 0x00000174B74262A0>, 'DDS': <function _save at 0x00000174B74267A0>, 'EPS': <function _save at 0x00000174B7426FC0>, 'GRIB': <function _save at 0x00000174B7D16660>, 'HDF5': <function _save at 0x00000174B7D16B60>, 'JPEG2000': <function _save at 0x00000174B7D34900>, 'ICNS': <function _save at 0x00000174B7D34B80>, 'ICO': <function _save at 0x00000174B7D35260>, 'IM': <function _save at 0x00000174B7D362A0>, 'TIFF': <function _save at 0x00000174B7D61EE0>, 'MPO': <function _save at 0x00000174B7D37380>, 'MSP': <function _save at 0x00000174B7D8E660>, 'PALM': <function _save at 0x00000174B7D8EAC0>, 'PDF': <function _save at 0x00000174B7E00360>, 'QOI': <function _save at 0x00000174B7E022A0>, 'SGI': <function _save at 0x00000174B7E02A20>, 'SPIDER': <function _save_spider at 0x00000174B7E03880>, 'TGA': <function _save at 0x00000174B7E03BA0>, 'WEBP': <function _save at 0x00000174B7E10A40>, 'WMF': <function _save at 0x00000174B7E10FE0>, 'XBM': <function _save at 0x00000174B7E11580>}
    Testing /foo/bar.apng
    Testing /foo/bar.avif
    Testing /foo/bar.avifs
    Testing /foo/bar.blp
    Testing /foo/bar.bmp
    Traceback (most recent call last):
      File "d:\code\image viewer\tk_image_viewer\test.py", line 106, in <module>
        test_save()
        ~~~~~~~~~^^
      File "D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\pyfakefs\fake_filesystem_unittest.py", line 230, in wrapped
        return f(*args, **kwargs)
      File "d:\code\image viewer\tk_image_viewer\test.py", line 101, in test_save
        im.save(fname)
        ~~~~~~~^^^^^^^
      File "D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL\Image.py", line 2590, in save
        save_handler(self, fp, filename)
        ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
      File "D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL\BmpImagePlugin.py", line 493, in _save
        ImageFile._save(
        ~~~~~~~~~~~~~~~^
            im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        )
        ^
      File "D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL\ImageFile.py", line 662, in _save
        _encode_tile(im, fp, tile, bufsize, fh)
        ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "D:\code\image viewer\tk_image_viewer\venv\Lib\site-packages\PIL\ImageFile.py", line 697, in _encode_tile
        errcode = encoder.encode_to_file(fh, bufsize)
    OSError: [Errno 9] Bad file descriptor
  3. changed the title [-]Can't save APNG to BytesIO[/-] [+]Can't save all extensions to BytesIO[/+] on Feb 11, 2026
  4. radarhere commented on Feb 11, 2026

    @radarhere
    Member

    To be clear,

    from io import BytesIO
    from PIL import Image
    
    im = Image.open("eye.gif")
    out = BytesIO()
    im.save(out, "PNG")

    works with Pillow 12.1.0 and Python 3.13.

    Your extended code example is shows bar.apng working successfully. So now you're asking about bar.bmp. Testing, I find this is no longer a problem when I remove the use of pyfakefs.

    If pyfakefs isn't working where saving to a normal path does, that sounds like an issue to raise with https://github.com/pytest-dev/pyfakefs

  5. CTimmerman commented on Feb 11, 2026

    @CTimmerman
    ContributorAuthor

    Then why did it work on the extensions before bmp? Anyway, I just want to test all extensions without writing to disk.

  6. radarhere commented on Feb 11, 2026

    @radarhere
    Member

    This would be my suggestion for saving files in all formats.

    from io import BytesIO
    from PIL import Image
    Image.init()
    
    for format in Image.SAVE:
    	for mode in ("L", "P"):
    		out = BytesIO()
    		im = Image.new(mode, (1, 1))
    		try:
    			im.save(out, format)
    		except OSError as e:
    			if str(e).endswith(" handler not installed"):
    				print(format+": Handler not installed")
    				break
    		except ValueError:
    			continue
    		print(format+": Success")
    		break
    	else:
    		print(format+": Unable to save in any mode that was tried")

    If you do explicitly want to use an extension without saving to disk, this would be my suggestion.

    from io import BytesIO
    from PIL import Image
    Image.init()
    
    for ext in Image.EXTENSION:
    	if not Image.EXTENSION[ext] in Image.SAVE:
    		continue
    
    	for mode in ("L", "P", "RGB"):
    		out = BytesIO()
    		out.name = "out"+ext
    		im = Image.new(mode, (1, 1))
    		try:
    			im.save(out)
    		except OSError as e:
    			if str(e).endswith(" handler not installed"):
    				print(ext+": Handler not installed")
    				break
    		except ValueError:
    			continue
    		print(ext+": Success")
    		break
    	else:
    		print(ext+": Unable to save in any mode that was tried")
  7. CTimmerman commented on Feb 11, 2026

    @CTimmerman
    ContributorAuthor

    out.name = "out"+ext

    I googled that but could not find it used or documented (and I disabled member warnings for my global hacks). Thanks!

    Why then the format parameter, and why does that not accept "BW" for example?

  8. radarhere commented on Feb 11, 2026

    @radarhere
    Member

    The format parameter has been on the save() method since we forked from PIL, so I can't give you any kind of official explanation.

    My guess would be that a file extension was thought of as just as an an alias for the image format - who cares if it is .tif or .tiff? If there's no filename, the exact extension is usually meaningless, and what really matters is what type of image it is, so let's have one single identifier for that, the format. It's perhaps also worth noting that the SPIDER image format doesn't have any extensions, so we couldn't rely purely on extensions.

    ".bw" is a file extension for the "SGI" format. We could make the format parameter more flexible, to also accept extensions, but without a concrete need for that functionality, the additional possibility seems confusing, rather than clarifying, for new users. There's already a clear mapping from file extensions to formats in the form of Image.EXTENSION, and I can't recall this ever previously being raised.

  9. CTimmerman commented on Feb 11, 2026

    @CTimmerman
    ContributorAuthor

    I don't think <24-bit images are as popular as they once were, and even with the BytesIO instance out.name = "bla.bw", im.save(out) fails with ValueError: Unsupported SGI image mode, so if the SGI format covers the BW extension, it should autoconvert to mode L.
    It accepts and writes mode RGB but SGI extension ".bw" should be mode L. And SGI extension ".rgba" should be RGBA.

  10. radarhere commented on Feb 12, 2026

    @radarhere
    Member

    I think you're making two requests.

    1. If a user tries to save an image with an unsupported mode, it should be converted.

    Not all image conversion is lossless, and losing data without notifying the user doesn't sound ideal to me. We do so for GIF at least, but I'm not sure it should be universal. Feel free to see if others disagree with me. As I said, I think it worth looking at #2663, an open issue with a suggestion for how to make this easier.

    1. You'd like us to automatically convert the image based on the file extension.

    We do use the filename extension for the image type when saving JPEG2000 images (not the mode), but that's the only extension-based plugin-specific data handling that I'm aware of.

    I think it's a reasonably fundamental concept of computing that users can name their files whatever they like. I just opened GIMP and exported a JPEG with a '.png' extension. Granted, it made me confirm what I was doing, but it still allowed me to do so in the end.

    It also seems a little odd given that you just went through effort to be able to save images without a filename. Earlier in this issue you tried to save a single frame image as an APNG. Conceptually, I'm not sold that images meaningfully have, or need, an extension if they don't have a filename.

  11. CTimmerman commented on Feb 12, 2026

    @CTimmerman
    ContributorAuthor

    I've upvoted #2663 but as the issue is from 2017, it might take another year or 5 to get merged, so I've already written my own conversion code that doesn't bother the user who should know that ".bw" is for black and white files (probably why the user typed it in the first place). Magic bytes require reading the file, and MIME types require a confusing and ugly separate metadata channel like MacOS's __MACOSX folder.

    As the issue with testing without burdening the disk is resolved by using the undocumented name property of a BytesIO object, this issue can remain as a bug for putting RGB in a BW and similar unexpected situations.

  12. radarhere commented on Feb 13, 2026

    @radarhere
    Member

    the user who should know that ".bw" is for black and white files

    Magic bytes require reading the file

    While it would nice if all file extensions were correct, I think Pillow acknowledges that this may not be the case. https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#image-file-formats

    The open() function identifies files from their contents, not their names, but the save() method looks at the name to determine which format to use, unless the format is given explicitly.

    We allow a JPEG to be saved with a '.png' extension.

    >>> from PIL import Image
    >>> im = Image.new("1", (1, 1))
    >>> im.save("out.png", "JPEG")
  13. CTimmerman commented on Feb 13, 2026

    @CTimmerman
    ContributorAuthor

    Fine as you're reading the file anyway, but im.save("out.sgi", "BW") doesn't work.

  14. radarhere commented on Feb 13, 2026

    @radarhere
    Member

    I think this has already been discussed.

    #9428 (comment)

    why does that not accept "BW" for example?

    #9428 (comment)

    ".bw" is a file extension for the "SGI" format. We could make the format parameter more flexible, to also accept extensions, but without a concrete need for that functionality, the additional possibility seems confusing, rather than clarifying, for new users. There's already a clear mapping from file extensions to formats in the form of Image.EXTENSION, and I can't recall this ever previously being raised.

    If you disagree with me, I can just leave this issue open for a while for others to chime in, or you can create a PR to make your proposal more concrete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions