How to Convert PNG to ICO Using PowerShell

I have lost count of how many times I have needed a favicon at short notice and ended up on some random online converter, uploading a company logo I probably shouldn’t be handing to a stranger’s server. It works, but it always feels wrong.

The confusing part is that Windows has no built-in ICO cmdlet. There is no ConvertTo-Icon. So when people search for how to convert PNG to ICO using PowerShell, they find three answers that all look different, and two of them quietly produce a single blurry frame.

In this tutorial, I will show you how to convert PNG to ICO using PowerShell with nothing but .NET classes that already ship with Windows. No modules, no admin rights, no uploads. We will build a working batch converter around one small folder of sample images.

I will also explain why the popular one-line trick gives you a 32×32-only icon, and how to write a proper multi-resolution ICO instead.

Key Takeaways

  • PowerShell has no native ICO cmdlet, so every method here calls the System.Drawing assembly built into Windows.
  • The quick Bitmap.Save method is fine for throwaway icons but produces one frame with poor small-size scaling.
  • Writing the ICO header yourself gives real multi-resolution icons at 16, 32, 48, 64 and 256 pixels.
  • Always dispose of bitmaps and streams, or your source PNG stays locked until the session closes.
  • Use -ErrorAction Stop inside try/catch, because non-terminating errors slip straight past catch.

Prerequisites

  • Windows PowerShell 5.1 or PowerShell 7.4+, running on Windows.
  • The System.Drawing assembly, already present on Windows 11. Confirm with Add-Type -AssemblyName System.Drawing.
  • No external modules and no elevated session required.
  • If you save this as a .ps1, run Get-ExecutionPolicy. If it returns Restricted, use Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned.
  • Write access to your chosen output folder.

What Converting PNG to ICO Actually Does

An ICO file is not a single image. It is a container: a 6-byte header, one 16-byte directory entry per image, then the image payloads themselves.

Here is the detail that makes this easy. Since Windows Vista, an ICO entry may hold a raw PNG stream verbatim. So converting PNG to ICO is mostly a packaging job, not a re-encode.

Use it when:

  • You need a favicon, shortcut icon, or tray icon from an existing PNG.
  • You are generating icons as part of a build or deployment script.
  • Company artwork must not leave your machine.

Don’t use it when:

  • Your source PNG is small. Upscaling to 256 pixels looks terrible.
  • You need hand-tuned 16×16 pixel art. Automated downscaling loses fine detail.
  • You are on Linux or macOS, where System.Drawing graphics calls are unsupported.

Check out Convert SHA256 to MD5 in PowerShell

The Short Answer

If you only read one section, this is it. Load the PNG, resize it, and save with the ICO encoder:

Add-Type -AssemblyName System.Drawing

$SourceImage = [System.Drawing.Image]::FromFile("C:\IconLab\Source\logo-primary.png")
$IconBitmap  = New-Object System.Drawing.Bitmap -ArgumentList $SourceImage, 32, 32
$IconBitmap.Save("C:\IconLab\Output\logo-primary.ico", [System.Drawing.Imaging.ImageFormat]::Icon)
$IconBitmap.Dispose()
$SourceImage.Dispose()

That is a working single-size icon in five lines. Read on for the multi-size version, which is what you actually want for anything user-facing.

Syntax

There is no cmdlet here, so the syntax is the .NET surface we call.

[System.Drawing.Image]::FromFile([string] $Filename)

New-Object System.Drawing.Bitmap -ArgumentList <Image>, <Width>, <Height>

<Bitmap>.Save([string] $Filename, [System.Drawing.Imaging.ImageFormat] $Format)

New-Object System.IO.BinaryWriter -ArgumentList <Stream>
ParameterTypeRequired?What it does
$FilenameStringYesFull path to the source PNG. Relative paths resolve against the .NET working directory, not your PowerShell location.
ImageSystem.Drawing.ImageYesThe loaded source used as the drawing source for the resize.
Width / HeightInt32YesTarget pixel dimensions for that frame.
$FormatImageFormatYesOutput encoder. Use Png for embedded frames, Icon for the quick path.
StreamSystem.IO.StreamYesDestination FileStream that receives the ICO bytes.

Note: None of these .NET methods accept pipeline input. All the pipeline work in this article happens in Get-ChildItem feeding a function with a process block.

Check out How to Convert JPG to PDF Using PowerShell

Setting Up the Example Data

Below I have a folder of five PNG logos, the kind of set you get handed after a rebrand, and every example reuses exactly these files.

New-Item -Path "C:\IconLab\Source" -ItemType Directory -Force | Out-Null
New-Item -Path "C:\IconLab\Output" -ItemType Directory -Force | Out-Null

Add-Type -AssemblyName System.Drawing

$Bitmap   = New-Object System.Drawing.Bitmap -ArgumentList 256, 256
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
$Graphics.Clear([System.Drawing.Color]::SteelBlue)
$Graphics.Dispose()
$Bitmap.Save("C:\IconLab\Source\logo-primary.png", [System.Drawing.Imaging.ImageFormat]::Png)
$Bitmap.Dispose()

Repeat those six lines for the remaining four names and sizes in the table below, changing the color and dimensions each time.

FileSource sizeColourIntended use
logo-primary.png256×256SteelBlueDesktop shortcut icon
logo-mono.png256×256DimGrayDisabled-state icon
icon-app.png128×128SeaGreenTray icon
icon-alert.png64×64FirebrickNotification icon
favicon-source.png512×512DarkOrchidWebsite favicon

How to Convert PNG to ICO Using PowerShell

I will cover both methods here, because which one you want depends entirely on who sees the icon.

The quick single-size method

  1. Open PowerShell as your normal user. No elevation needed.
  2. Load the assembly with Add-Type.
  3. Load logo-primary.png, resize into a 32×32 bitmap, save with the Icon encoder.
  4. Dispose of both objects.
Add-Type -AssemblyName System.Drawing

$SourcePath = "C:\IconLab\Source\logo-primary.png"
$TargetPath = "C:\IconLab\Output\logo-primary.ico"

$SourceImage = [System.Drawing.Image]::FromFile($SourcePath)
$IconBitmap  = New-Object System.Drawing.Bitmap -ArgumentList $SourceImage, 32, 32
$IconBitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Icon)
$IconBitmap.Dispose()
$SourceImage.Dispose()

Get-Item -Path $TargetPath | Select-Object -Property Name, Length, LastWriteTime

Here is the output:

Name             Length LastWriteTime
----             ------ -------------
logo-primary.ico    519 25-08-2026 21:24:11

I used 32 rather than 256 deliberately. The GDI+ Icon encoder has a long-standing habit of producing unusable output above 255 pixels, because the ICO directory stores each dimension in a single byte.

You can see the exact output in the screenshot below:

Convert PNG to ICO Using PowerShell

The multi-size method

This is the version I actually keep. Instead of asking GDI+ to build an icon, we encode several PNG frames and assemble the container ourselves.

Add-Type -AssemblyName System.Drawing

$SourcePath = "C:\IconLab\Source\favicon-source.png"
$TargetPath = "C:\IconLab\Output\favicon.ico"
$IconSizes  = @(16, 32, 48, 64, 256)

try {
    $SourceImage = [System.Drawing.Image]::FromFile($SourcePath)
    $FrameList   = [System.Collections.Generic.List[byte[]]]::new()

    foreach ($Size in $IconSizes) {
        $Frame  = New-Object System.Drawing.Bitmap -ArgumentList $SourceImage, $Size, $Size
        $Buffer = New-Object System.IO.MemoryStream
        $Frame.Save($Buffer, [System.Drawing.Imaging.ImageFormat]::Png)
        $FrameList.Add($Buffer.ToArray())
        $Buffer.Dispose()
        $Frame.Dispose()
    }

    $FileStream = [System.IO.File]::Create($TargetPath)
    $Writer     = New-Object System.IO.BinaryWriter -ArgumentList $FileStream

    $Writer.Write([int16]0)                   # reserved
    $Writer.Write([int16]1)                   # type 1 = icon
    $Writer.Write([int16]$FrameList.Count)    # frame count

    $Offset = 6 + (16 * $FrameList.Count)
    for ($Index = 0; $Index -lt $FrameList.Count; $Index++) {
        $Dimension = $IconSizes[$Index]
        $Writer.Write([byte]($Dimension -band 0xFF))   # 256 stores as 0
        $Writer.Write([byte]($Dimension -band 0xFF))
        $Writer.Write([byte]0)                         # palette count
        $Writer.Write([byte]0)                         # reserved
        $Writer.Write([int16]1)                        # colour planes
        $Writer.Write([int16]32)                       # bits per pixel
        $Writer.Write([int32]$FrameList[$Index].Length)
        $Writer.Write([int32]$Offset)
        $Offset += $FrameList[$Index].Length
    }

    foreach ($Frame in $FrameList) { $Writer.Write($Frame) }

    $Writer.Flush()
    $Writer.Dispose()
    $FileStream.Dispose()
    $SourceImage.Dispose()

    Get-Item -Path $TargetPath -ErrorAction Stop | Select-Object -Property Name, Length
}
catch {
    Write-Error "ICO build failed: $($_.Exception.Message)"
}
Name        Length
----        ------
favicon.ico  14462

Here is the exact output in the screenshot below:

PowerShell Convert PNG to ICO

How does this command work?

  • Add-Type -AssemblyName System.Drawing loads the GDI+ wrapper. Without it, [System.Drawing.Image] is an unresolvable type in PowerShell 7.
  • FromFile reads the PNG into memory and holds a file lock until disposed.
  • New-Object System.Drawing.Bitmap -ArgumentList $SourceImage, $Size, $Size uses the resizing constructor, redrawing the source at the new dimensions.
  • [System.Collections.Generic.List[byte[]]]::new() gives a growable list. I use this rather than $FrameList += $Frame, which rebuilds the whole array on every iteration.
  • Each frame is encoded straight into a MemoryStream, so no temporary files touch disk.
  • $Writer.Write([int16]1) sets the resource type. Type 1 is an icon, type 2 would be a cursor.
  • $Offset = 6 + (16 * $FrameList.Count) calculates where payloads begin: 6 header bytes plus 16 bytes per directory entry.
  • $Dimension -band 0xFF masks 256 down to 0, which is the spec’s way of encoding a 256-pixel frame in one byte. Miss this and the write throws.
  • -ErrorAction Stop on Get-Item converts a non-terminating error into a terminating one, so catch actually sees it.

Right-click the finished favicon.ico, open Properties, and Windows should render it crisply at every preview size.

Pro Tip: Never use Write-Host to report results from a converter like this. Return objects or use Write-Output, so the caller can pipe results into Export-Csv or Where-ObjectWrite-Host writes to the display and is invisible to the pipeline.

Read Get Folder Size in PowerShell Including Subfolders

How to Convert a Whole Folder of PNGs to ICO

Now let us run that logic across all five sample files. Wrap the builder in an advanced function and let the pipeline feed it.

  1. Turn the header-writing block into a function with a process block.
  2. Enumerate the source folder with Get-ChildItem -Filter "*.png".
  3. Pipe straight into the function.
  4. Return one summary object per conversion.
function Convert-PngToIcon {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string] $FullName,

        [Parameter(Mandatory = $true)]
        [string] $OutputFolder,

        [Parameter(Mandatory = $false)]
        [int[]] $IconSizes = @(16, 32, 48, 64, 256)
    )

    process {
        Add-Type -AssemblyName System.Drawing

        $BaseName   = [System.IO.Path]::GetFileNameWithoutExtension($FullName)
        $TargetPath = Join-Path -Path $OutputFolder -ChildPath "$BaseName.ico"

        try {
            $SourceImage = [System.Drawing.Image]::FromFile($FullName)
            $UsableSizes = $IconSizes | Where-Object { $_ -le $SourceImage.Width }

            # ... frame encoding and ICONDIR writing block from above,
            # ... using $UsableSizes in place of $IconSizes ...

            [pscustomobject]@{
                Source   = $BaseName
                IconPath = $TargetPath
                Frames   = $UsableSizes.Count
                SizeKB   = [math]::Round((Get-Item -Path $TargetPath -ErrorAction Stop).Length / 1KB, 1)
                Status   = "Converted"
            }
        }
        catch {
            [pscustomobject]@{
                Source   = $BaseName
                IconPath = $TargetPath
                Frames   = 0
                SizeKB   = 0
                Status   = "Failed: $($_.Exception.Message)"
            }
        }
    }
}

Get-ChildItem -Path "C:\IconLab\Source" -Filter "*.png" -File |
    Convert-PngToIcon -OutputFolder "C:\IconLab\Output" |
    Format-Table -AutoSize
Source         IconPath                                  Frames SizeKB Status
------         --------                                  ------ ------ ---------
favicon-source C:\IconLab\Output\favicon-source.ico           5   34.1 Converted
icon-alert     C:\IconLab\Output\icon-alert.ico               3    1.6 Converted
icon-app       C:\IconLab\Output\icon-app.ico                 4    2.9 Converted
logo-mono      C:\IconLab\Output\logo-mono.ico                5   12.4 Converted
logo-primary   C:\IconLab\Output\logo-primary.ico             5   12.8 Converted

How does this command work?

  • [CmdletBinding()] makes this an advanced function, which is what gives you -Verbose and proper -ErrorAction support for free.
  • ValueFromPipelineByPropertyName = $true on $FullName is why Get-ChildItem binds directly, since FileInfo objects already expose a FullName property.
  • The process block runs once per piped object, so the function streams instead of waiting for the whole collection.
  • -Filter "*.png" filters at the provider level rather than pulling every file into PowerShell and discarding it later.
  • $IconSizes | Where-Object { $_ -le $SourceImage.Width } skips any frame larger than the source, which is why the 64px icon-alert.png produced only three frames.
  • Returning a [pscustomobject] from both branches means one failure never breaks the run.

Caution: [System.IO.File]::Create() overwrites silently, with no prompt and no -WhatIf. Preview first with Get-ChildItem -Path "C:\IconLab\Output" -Filter "*.ico" | Remove-Item -WhatIf.

Check out Convert HTML to PDF in PowerShell

Bitmap.Save vs Manual ICO Writing Comparison

AspectBitmap.Save with ImageFormat::IconManual ICONDIR writer
Lines of code5~30
Frames produced1As many as you list
Max usable sizeUnreliable above 255px256px fully supported
Alpha channelOften flattenedPreserved via embedded PNG
Quality at 16pxBlurrySharp, purpose-scaled
File sizeSmallerTypically 10–40 KB

Bitmap.Save is genuinely fine for a throwaway shortcut icon nobody will look at closely. The manual writer wins the moment the icon is user-facing, because browsers pull the 16px frame for tabs and the 32px frame for bookmarks, and a single downscaled 256px image looks soft in both.

Decision rule: use Bitmap.Save for a quick single-size icon, and the manual ICONDIR writer for anything a user or browser will actually see.

Performance and Scripting Best Practices

Filtering left is the biggest win. -Filter "*.png" pushes the pattern down to the filesystem provider, while Where-Object { $_.Extension -eq ".png" } retrieves every file object first and throws most away.

The other trap is $Array += $Item inside a loop. .NET arrays are fixed length, so each += allocates a new array and copies everything across.

  1. Use -Filter for wildcard patterns, and reserve Where-Object for property logic like $_.Length -gt 10KB.
  2. Replace += with [System.Collections.Generic.List[T]] and .Add().
  3. Dispose every BitmapGraphicsMemoryStream and BinaryWriter, since GDI+ handles are unmanaged.
  4. Add -File to Get-ChildItem so directories never enter the pipeline.
  5. Emit [pscustomobject] results rather than formatted strings, so Export-Csv still works downstream.

Pro Tip: If you convert hundreds of files in one session, call [System.GC]::Collect() once at the end, never inside the loop. Forcing collection per iteration costs more than the memory it frees.

Read PowerShell Convert HTML to Plain Text

Compatibility Notes

BehaviourWindows PowerShell 5.1PowerShell 7.4+ (Windows)PowerShell 7.4+ (Linux/macOS)
Add-Type -AssemblyName System.DrawingLoadsLoads via System.Drawing.CommonLoads, but graphics calls throw
[System.Drawing.Image]::FromFileSupportedSupportedPlatformNotSupportedException
Bitmap resize constructorSupportedSupportedNot supported
ImageFormat::Icon encoderSupportedSupportedNot supported
BinaryWriter header writingSupportedSupportedSupported
[List[byte[]]]::new()SupportedSupportedSupported

Note: System.Drawing graphics operations have been Windows-only since .NET 6. The header-writing half is fully cross-platform, so on Linux you would swap the resize step for another imaging library and keep the rest.

Common Errors and How to Fix Them

Error messageWhy it happensThe fix
"...logo.png is not a valid path."Path missing, or a relative path resolved somewhere unexpectedUse a full path, or (Resolve-Path -Path $File).Path
Unable to find type [System.Drawing.Image].Assembly never loaded in this sessionRun Add-Type -AssemblyName System.Drawing first
A generic error occurred in GDI+.Output folder missing, or file locked by Explorer’s preview paneCreate the folder first and close the preview window
The process cannot access the file ... used by another process.An earlier FromFile was never disposedCall .Dispose() on every Image and Bitmap, ideally in finally
Cannot convert argument "0", with value: "256" ... to type "System.Byte"256 will not fit in the ICONDIRENTRY width byteMask it: [byte]($Dimension -band 0xFF)
System.OutOfMemoryExceptionLarge images loaded repeatedly without disposalDispose per iteration and avoid += growth
Export-Csv : Cannot bind argument to parameter 'InputObject' because it is null.Nothing was pending, so $Results was emptyGuard with if ($Results) { ... }

Things to Keep in Mind

  • Dispose everything. Leaked GDI+ handles are the number one cause of “file in use” errors in image scripts.
  • 256 is the ICO ceiling. Width and height each occupy one byte, so nothing larger can be described.
  • Never upscale. Filter your size list against the source width, or a 64px logo turns to mush at 256.
  • Execution policy bites saved scripts, not pasted commands. If it runs interactively but fails as a .ps1, check Get-ExecutionPolicy -List.
  • -WhatIf does not exist on .NET methods. Only cmdlets support it, so guard overwrites with Test-Path.
  • Non-terminating errors skip catch. Add -ErrorAction Stop wherever failure should abort the block.
  • No admin rights are needed, provided you can write to the output folder.

Frequently Asked Questions

Do I need to install anything to convert PNG to ICO using PowerShell?

No. System.Drawing ships with Windows, and every example here runs on a stock Windows 11 machine without modules or elevation.

Why does my icon look blurry in the browser tab?

You almost certainly produced a single large frame. Browsers downscale it themselves, and the result is soft. Use the multi-size writer so a purpose-scaled 16px frame exists.

Can I convert JPG or BMP the same way?

Yes. [System.Drawing.Image]::FromFile handles JPG, BMP, GIF and TIFF too. Only change the -Filter value in Get-ChildItem.

Why is 256 written as 0 in the header?

The ICONDIRENTRY stores width and height in one byte each, so the maximum literal value is 255. The spec defines 0 as meaning 256.

Does this preserve transparency?

Yes, with the manual writer, because each frame is an embedded 32-bit PNG. The quick Bitmap.Save path frequently flattens alpha.

Can I run this on Linux or macOS?

Not as written. The resize step needs Windows GDI+. The header-writing logic is portable if you substitute a cross-platform imaging library such as ImageSharp or SkiaSharp.

How do I add a size that is not in the default list?

Pass it in: Convert-PngToIcon -IconSizes @(24, 48, 96). Anything larger than the source is dropped automatically.

Conclusion

Converting PNG to ICO using PowerShell comes down to one decision. If the icon is disposable, Bitmap.Save with the Icon encoder gets you there in five lines. If a user or a browser will see it, spend the extra twenty lines and write the ICONDIR yourself.

The manual writer is not really image processing at all. It is byte packaging, and once you have the header laid out correctly, it never needs touching again.

Drop Convert-PngToIcon into your profile or a small module, and icon generation becomes one more thing your build script handles quietly. Try it against your own brand folder next, and check the 16px frame in a real browser tab before you ship it.

You may also like:

100 PowerShell cmdlets download free

100 POWERSHELL CMDLETS E-BOOK

FREE Download an eBook that contains 100 PowerShell cmdlets with complete script and examples.