Deep Dives

ICO Format Decoded: Structure, Headers, and Why It Survived

koboshiCo-founder
·16 min read
ICO Format Decoded: Structure, Headers, and Why It Survived
Summary

ICO is a container, not an image codec. Invented for Windows 1.0 in 1985, it stores multiple resolutions of the same icon in one file so the OS can pick the right size at runtime. Here is the complete technical breakdown — from the six-byte header to why SVG favicons still have not killed it off.

Open an ICO file in a hex editor. The first six bytes:

00 00 01 00 03 00

That is the entire file header. 00 00 is the Reserved field — always zero. 01 00 is the Type field — 1 means this is an icon file (cursor files use 2). 03 00 is the Count field — little-endian for 3, meaning this ICO contains three separate images. Six bytes to describe a container that holds three independent image files. The rest of the file is metadata about those images, followed by the image data itself.

ICO is neither a compression algorithm nor a color space — it is simply a container, the smallest and most primitive container format still in daily use on every Windows machine and every major browser.

Where ICO Came From

Microsoft introduced ICO with Windows 1.0 in 1985. The PC ecosystem in 1985 had no concept of a standardized image file. There was no JPEG, no PNG, no GIF. Bitmap data was raw arrays of pixels, and every program handled its own storage. Microsoft needed a way to ship icons for programs, folders, and system UI elements. The requirements were simple:

  • One file per icon, regardless of how many sizes the OS needed
  • Fast lookup at runtime — the OS should not have to decode an image to know its dimensions
  • Small memory footprint on systems with 256 KB of RAM

The solution was a directory structure. The file starts with a header that says how many images are inside. Then comes an array of entries, each describing one image's width, height, bit depth, and offset within the file. The OS reads the directory, picks the entry that matches the current display context, seeks to that offset, and renders the bitmap.

This design predates ZIP by two years, predates GIF by two years, and predates JPEG by seven years. ICO was a miniature filesystem before filesystems became interesting.

Why ICO Exists at All

An icon is not a single image — it is a set of images at different resolutions. Windows renders the same icon at 16 x 16 in a file list, at 32 x 32 on the desktop, at 48 x 48 in the Explorer detail pane, and at 256 x 256 in the "extra large" view. A high-DPI display at 200% scale needs a 32 x 32 icon rendered from a 64 x 64 source. The OS decides which size to use based on context, and that choice has to be instant.

If icons were stored as individual PNG files, the OS would need to open multiple files, decode each one, and cache the results. With ICO, the OS opens one file, reads a 16-byte directory entry, and jumps directly to the bitmap data. The directory makes the format self-describing. No decoder needed for metadata.

The same logic applies to favicons. When a browser requests /favicon.ico, it gets one file that contains every size it might need — 16 x 16 for the tab, 32 x 32 for the bookmark bar, 180 x 180 for iOS home screen shortcuts. The browser picks the right entry without parsing image headers.

The File Header

The ICO header has two parts: the ICONDIR and the ICONDIRENTRY array.

ICONDIR (6 bytes)

Bytes 0-1: Reserved     (must be 0)
Bytes 2-3: Type         (1 = icon, 2 = cursor)
Bytes 4-5: Count        (number of images, little-endian uint16)

Every ICO file starts with 00 00 01 00. The next two bytes tell you how many images follow.

ICONDIRENTRY (16 bytes each)

Each image gets one entry:

Bytes 0:    Width         (in pixels; 0 means 256)
Bytes 1:    Height        (in pixels; 0 means 256)
Bytes 2:    ColorCount    (0 if more than 256 colors)
Bytes 3:    Reserved      (always 0)
Bytes 4-5:  Planes        (1 for icons)
Bytes 6-7:  BitCount      (bits per pixel: 1, 4, 8, 24, or 32)
Bytes 8-11: BytesInRes    (size of image data in bytes, uint32)
Bytes 12-15: ImageOffset  (byte offset to image data from file start, uint32)

The Width and Height fields are one byte each. The maximum explicitly storable value is 255. When an image is 256 x 256 pixels, the field stores 0x00, which decoders interpret as 256. This quirk has been part of the specification since 1985.

Here is what the directory looks like for an ICO with three images — a 16 x 16 BMP, a 32 x 32 BMP, and a 256 x 256 PNG:

Offset 0x00:  00 00 01 00 03 00                    -- ICONDIR: 3 images
Offset 0x06:  10 10 00 00 01 00 18 00 2C 01 00 00 16 00 00 00  -- 16x16, 24bpp, BMP, 300 bytes at 0x16
Offset 0x16:  20 20 00 00 01 00 18 00 A8 02 00 00 42 01 00 00  -- 32x32, 24bpp, BMP, 680 bytes at 0x142
Offset 0x26:  00 00 00 00 01 00 20 00 B4 1C 00 00 EA 03 00 00  -- 256x256, 32bpp, PNG, 7348 bytes at 0x3EA

The OS reads the directory, finds the entry matching its needs, and seeks to ImageOffset — no parsing, no guessing, just direct memory access.

Why PNG, WebP, and JPEG Never Replaced ICO

By every modern metric, PNG is a better format than ICO's internal bitmap encoding. PNG has better compression, true alpha transparency, and widespread tooling. WebP is smaller still. JPEG handles photographs. Yet ICO persists. Three reasons:

1. Single-file multi-resolution. PNG stores one image. ICO stores an arbitrary number. A website could serve a ZIP of PNGs, but no browser would know how to pick the right one for a favicon. The ICO directory structure solves this in six bytes.

2. System API binding. Windows LoadIcon, ExtractIcon, and SHGetFileInfo all expect ICO data. The Win32 API has no equivalent for PNG icon containers. Changing this would break every Windows application compiled since 1985, and Microsoft has never been in the business of breaking backward compatibility.

3. The favicon standard. The HTML <link rel="icon"> tag accepts PNG, SVG, and ICO, but the implicit default request for /favicon.ico predates those options. Every browser since Internet Explorer 5 (1999) requests /favicon.ico by default. Sites that do not explicitly declare a favicon link still need an ICO at that path or the browser gets a 404.

PNG did not replace ICO because ICO was never competing on image quality. It was competing on container semantics, and no other format offers the same directory-in-a-file model with thirty years of operating system support.

What Is Inside the Container

Each entry in an ICO file points to an independent image. The image data can be one of two formats:

BMP Encoding (Legacy)

Before Windows Vista, all ICO images were uncompressed or RLE-compressed BMP bitmaps. The stored data is a DIB (Device Independent Bitmap) — a BMP without the BITMAPFILEHEADER. It starts directly with the BITMAPINFOHEADER:

Bytes 0-3:   Header size      (40 for BITMAPINFOHEADER)
Bytes 4-7:   Width            (uint32)
Bytes 8-11:  Height           (uint32, doubled for XOR + AND masks)
Bytes 12-13: Planes           (1)
Bytes 14-15: BitCount         (1, 4, 8, 24, or 32)
Bytes 16-19: Compression      (0 for uncompressed, 1 or 2 for RLE)
Bytes 20-23: Image size       (0 if uncompressed)
Bytes 24-27: X pixels per meter
Bytes 28-31: Y pixels per meter
Bytes 32-35: Colors used
Bytes 36-39: Important colors

The Height field in the DIB header is double the actual icon height. A 32 x 32 icon stores 64 in the Height field. The top half is the XOR mask (the actual color image), and the bottom half is the AND mask (a 1-bit transparency bitmap). For 32-bit icons with an alpha channel, the AND mask is typically ignored.

For 24-bit and lower BMP-encoded icons without alpha, the AND mask is the only transparency mechanism. A 1 in the AND mask means transparent; a 0 means opaque. This is how Windows achieved transparency before 32-bit color became standard.

PNG Encoding (Windows Vista+)

Starting with Windows Vista, ICO files can store PNG-encoded images. The image data at the offset specified in ICONDIRENTRY is a raw PNG file — complete with its own 89 50 4E 47 signature and full PNG chunk structure.

This is the format you want for 256 x 256 icons. A 256 x 256 32-bit BMP would be approximately 262 KB uncompressed. The same image as PNG is typically 20-60 KB. Modern icon tools generate PNG-encoded entries for 256 x 256 and BMP-encoded entries for smaller sizes, giving the best balance of compatibility and file size.

Chunk Comparison

EncodingIntroducedCompressionAlpha SupportBest For
BMPWindows 1.0 (1985)None or RLE1-bit AND mask16 x 16 to 48 x 48
PNGWindows Vista (2006)DEFLATE8-bit alpha64 x 64 to 256 x 256

Detecting and Inspecting ICO Files

Do not trust the .ico extension. Read the first six bytes and parse the directory.

TypeScript

interface IcoEntry {
  width: number
  height: number
  bitCount: number
  bytesInRes: number
  imageOffset: number
}

interface IcoInfo {
  valid: boolean
  type: "icon" | "cursor" | "unknown"
  count: number
  entries: IcoEntry[]
}

async function inspectIco(file: File): Promise<IcoInfo> {
  const buffer = await file.slice(0, 1024).arrayBuffer()
  const bytes = new Uint8Array(buffer)

  if (bytes.length < 6)
    return { valid: false, type: "unknown", count: 0, entries: [] }

  const reserved = bytes[0] | (bytes[1] << 8)
  const type = bytes[2] | (bytes[3] << 8)
  const count = bytes[4] | (bytes[5] << 8)

  if (reserved !== 0 || (type !== 1 && type !== 2)) {
    return { valid: false, type: "unknown", count: 0, entries: [] }
  }

  const entries: IcoEntry[] = []
  const dirSize = 6 + count * 16
  if (bytes.length < dirSize) {
    return { valid: false, type: "unknown", count: 0, entries: [] }
  }

  for (let i = 0; i < count; i++) {
    const off = 6 + i * 16
    entries.push({
      width: bytes[off] === 0 ? 256 : bytes[off],
      height: bytes[off + 1] === 0 ? 256 : bytes[off + 1],
      bitCount: bytes[off + 6] | (bytes[off + 7] << 8),
      bytesInRes:
        bytes[off + 8] |
        (bytes[off + 9] << 8) |
        (bytes[off + 10] << 16) |
        (bytes[off + 11] << 24),
      imageOffset:
        bytes[off + 12] |
        (bytes[off + 13] << 8) |
        (bytes[off + 14] << 16) |
        (bytes[off + 15] << 24),
    })
  }

  return {
    valid: true,
    type: type === 1 ? "icon" : "cursor",
    count,
    entries,
  }
}

Python

import struct
from typing import TypedDict

class IcoEntry(TypedDict):
    width: int
    height: int
    bit_count: int
    bytes_in_res: int
    image_offset: int

class IcoInfo(TypedDict):
    valid: bool
    type: str
    count: int
    entries: list[IcoEntry]

def inspect_ico(path: str) -> IcoInfo:
    with open(path, "rb") as f:
        header = f.read(1024)

    if len(header) < 6:
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    reserved, type_, count = struct.unpack("<HHH", header[:6])
    if reserved != 0 or type_ not in (1, 2):
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    dir_size = 6 + count * 16
    if len(header) < dir_size:
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    entries: list[IcoEntry] = []
    for i in range(count):
        off = 6 + i * 16
        w, h, colors, _, planes, bit_count = struct.unpack("<BBBBHH", header[off:off+8])
        bytes_in_res, image_offset = struct.unpack("<II", header[off+8:off+16])
        entries.append({
            "width": 256 if w == 0 else w,
            "height": 256 if h == 0 else h,
            "bit_count": bit_count,
            "bytes_in_res": bytes_in_res,
            "image_offset": image_offset,
        })

    return {
        "valid": True,
        "type": "icon" if type_ == 1 else "cursor",
        "count": count,
        "entries": entries,
    }

Go

package main

import (
	"encoding/binary"
	"fmt"
	"os"
)

type IcoEntry struct {
	Width        int
	Height       int
	BitCount     int
	BytesInRes   uint32
	ImageOffset  uint32
}

type IcoInfo struct {
	Valid   bool
	Type    string
	Count   int
	Entries []IcoEntry
}

func inspectIco(path string) (IcoInfo, error) {
	f, err := os.Open(path)
	if err != nil {
		return IcoInfo{}, err
	}
	defer f.Close()

	buf := make([]byte, 1024)
	n, _ := f.Read(buf)
	buf = buf[:n]

	if n < 6 {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	reserved := binary.LittleEndian.Uint16(buf[0:2])
	typ := binary.LittleEndian.Uint16(buf[2:4])
	count := binary.LittleEndian.Uint16(buf[4:6])

	if reserved != 0 || (typ != 1 && typ != 2) {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	dirSize := 6 + int(count)*16
	if n < dirSize {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	entries := make([]IcoEntry, 0, count)
	for i := 0; i < int(count); i++ {
		off := 6 + i*16
		w := buf[off]
		h := buf[off+1]
		bitCount := binary.LittleEndian.Uint16(buf[off+6 : off+8])
		bytesInRes := binary.LittleEndian.Uint32(buf[off+8 : off+12])
		imageOffset := binary.LittleEndian.Uint32(buf[off+12 : off+16])

		if w == 0 { w = 256 }
		if h == 0 { h = 256 }

		entries = append(entries, IcoEntry{
			Width:       int(w),
			Height:      int(h),
			BitCount:    int(bitCount),
			BytesInRes:  bytesInRes,
			ImageOffset: imageOffset,
		})
	}

	typStr := "icon"
	if typ == 2 {
		typStr = "cursor"
	}
	return IcoInfo{Valid: true, Type: typStr, Count: int(count), Entries: entries}, nil
}

PHP

function inspectIco(string $path): array {
    $header = file_get_contents($path, false, null, 0, 1024);
    if (strlen($header) < 6) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $reserved = unpack("v", substr($header, 0, 2))[1];
    $type = unpack("v", substr($header, 2, 2))[1];
    $count = unpack("v", substr($header, 4, 2))[1];

    if ($reserved !== 0 || ($type !== 1 && $type !== 2)) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $dirSize = 6 + $count * 16;
    if (strlen($header) < $dirSize) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $entries = [];
    for ($i = 0; $i < $count; $i++) {
        $off = 6 + $i * 16;
        $w = ord($header[$off]);
        $h = ord($header[$off + 1]);
        $bitCount = unpack("v", substr($header, $off + 6, 2))[1];
        $bytesInRes = unpack("V", substr($header, $off + 8, 4))[1];
        $imageOffset = unpack("V", substr($header, $off + 12, 4))[1];

        $entries[] = [
            "width" => $w === 0 ? 256 : $w,
            "height" => $h === 0 ? 256 : $h,
            "bit_count" => $bitCount,
            "bytes_in_res" => $bytesInRes,
            "image_offset" => $imageOffset,
        ];
    }

    return [
        "valid" => true,
        "type" => $type === 1 ? "icon" : "cursor",
        "count" => $count,
        "entries" => $entries,
    ];
}

ImageMagick CLI

magick identify -verbose favicon.ico | grep -E "(Print size|Resolution|Colorspace|Type)"

List all internal images:

magick identify favicon.ico

Extract a specific size:

magick favicon.ico[2] extracted-256x256.png

Or simply:

file favicon.ico
# favicon.ico: MS Windows icon resource - 5 icons, 16x16, 32 bits/pixel, 32x32, 32 bits/pixel

Best Practices and Use Cases

ICO is not a general-purpose image format. It is a deployment artifact for specific contexts. Use it where the context demands it, and use PNG everywhere else.

Favicons

For modern websites, serve both formats:

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />

Browsers pick the first format they support. SVG-aware browsers get the vector icon. Legacy browsers and bookmark utilities fall back to ICO. The ICO file should contain:

SizeEncodingPurpose
16 x 16BMPBrowser tab, legacy IE
32 x 32BMPTaskbar, bookmark bar
48 x 48BMPWindows shortcuts
180 x 180PNGiOS home screen icon
256 x 256PNGWindows Explorer extra-large view

A favicon ICO with these five entries is typically 30-50 KB. Without the 256 x 256 PNG, it drops to under 10 KB.

Windows Application Icons

Windows application executables embed an ICO resource. The OS loads the appropriate size from the embedded directory at runtime. For desktop applications targeting Windows 10 and 11, include:

  • 16 x 16, 24 x 24, 32 x 32, 48 x 48 (BMP, for legacy and standard DPI)
  • 64 x 64, 128 x 128, 256 x 256 (PNG, for high-DPI displays)

Windows automatically scales down if an exact size is missing, but scaling looks worse than rendering a native smaller size. Each size you omit costs visual quality.

When Not to Use ICO

  • Web content images: Use WebP, JPEG, or PNG. ICO has no compression advantage and no browser-native rendering benefit for inline images.
  • Photographs: ICO is not designed for high-color continuous-tone imagery. File sizes explode.
  • Cross-platform assets: macOS uses .icns, not ICO. Linux uses PNG or SVG. ICO is a Windows-native format that happens to work on the web.

The Future of ICO

ICO is going to stick around far longer than most obituaries predict. The reason is the same as its origin: backward compatibility.

SVG favicons are technically superior. They scale to any resolution, compress better than any raster format, and support animation and interactivity. Chrome, Firefox, and Safari all support SVG favicons. Yet ICO persists because millions of devices, enterprise systems, and legacy browsers still request /favicon.ico by default. A site without an ICO file generates 404s in server logs and shows a broken icon in older software.

Windows 11 still uses ICO for application icons, folder icons, and system UI. The Win32 API still expects ICO data. Microsoft has shown no interest in replacing this format — there is no business reason to break thirty years of application compatibility.

The real shift is in how ICO files are produced, not in the format itself. Modern toolchains — IconKitchen, Figma plugins, online generators — output ICO files with PNG-encoded high-resolution entries automatically. The container stays the same; the payload gets better.

Nobody loves ICO as a format. But it works everywhere, breaks nothing, and costs nothing to support — which is exactly why it will still be around in 2035.

If you need to create ICO files from existing images, JPG to ICO converts JPG, PNG, and WebP sources into multi-resolution ICO files directly in your browser — no uploads, no server processing. It generates the standard size matrix and automatically picks PNG encoding for 256 x 256 and BMP encoding for smaller sizes, giving you the optimal balance of compatibility and file size.

More blog posts to read