Square images work best. Nothing is uploaded.
The file this makes holds three images, at 16, 32 and 48 pixels, each stored as a PNG inside the ICO container. Browsers pick whichever fits. That is why a single resized PNG renamed .ico is not the same thing.
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
Put favicon.ico in the site root as well. Browsers ask for it there whether you link to it or not, and a missing one is a 404 in your logs on every visit.
That is the size people actually see, in a tab. A logo with words in it turns into grey mush. Use one letter, one shape or one strong silhouette, and check it at 16 before you commit. The preview above shows all three sizes at their real dimensions for that reason.
from PIL import Image
im = Image.open("logo.png").convert("RGBA")
im.save("favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
Pillow writes the multi size ICO in one call. For a padded square first:
from PIL import Image, ImageOps
im = Image.open("logo.png").convert("RGBA")
im = ImageOps.contain(im, (400, 400), Image.Resampling.LANCZOS)
square = Image.new("RGBA", (512, 512), (255, 255, 255, 255))
square.paste(im, ((512 - im.width) // 2, (512 - im.height) // 2), im)
square.save("favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
See format support for the rest of what Pillow writes.