Image Uploads & Processing
Reviewed & published by Brayan K
By the end of this lesson you'll resize photos without squashing them, build square thumbnails, stamp watermarks, convert to WebP, and fix sideways phone uploads — the exact pipeline behind every avatar and gallery on the web.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Tell GD and Imagick apart and check which is installed
- Load an existing image and create a blank canvas from scratch
- Resize and build thumbnails while keeping the aspect ratio
- Add watermarks and convert images to space-saving WebP
- Fix sideways phone photos and strip EXIF/GPS metadata
- Do it all in one line with the Intervention Image library
1️⃣ GD vs Imagick — Know Your Tools
PHP can't process images on its own — it leans on a library (a bundle of ready-made functions). There are two. GD is bundled with almost every PHP install and covers JPEG, PNG, GIF and WebP — it's what you'll use 90% of the time. Imagick is a separate extension that wraps the powerful ImageMagick program: it supports 200+ formats and gives sharper results, but it has to be installed on the server first. Before you write a single resize, check what you actually have.
If WebP Support says no or GD is missing, you'll get a fatal error later — so this is the first thing to confirm on any new server.
2️⃣ Loading & Creating Images
Every GD operation works on an image resource — the picture decompressed into memory. You get one two ways: load an existing file with imagecreatefromjpeg() (or ...png, ...webp), or create a blank canvas with imagecreatetruecolor(). Call getimagesize() first — it reads just the file header, so you learn the real type and dimensions cheaply, before loading megabytes into RAM.
<?php
// So this runs anywhere: paint a 1200x900 stand-in for the photo you would
// really be processing. Delete these five lines once you have a photo.jpg.
if (!file_exists('photo.jpg')) {
$demo = imagecreatetruecolor(1200, 900);
imagefill($demo, 0, 0, imagecolorallocate($demo, 120, 160, 200));
imagejpeg($demo, 'photo.jpg');
imagedestroy($demo);
}
// Two ways to get an image "resource" you can draw on or save.
// 1) LOAD an existing file. getimagesize() reads the header first so you know
// the real type and dimensions BEFORE loading the whole thing into memory.
[$w, $h, $type] = getimagesize('photo.jpg'); // [1200, 900, 2] here
echo "Source is {$w} x {$h}, type code {$type}\n"; // type 2 = IMAGETYPE_JPEG
$img = imagecreatefromjpeg('photo.jpg'); // a GD image resource in memory
// 2) CREATE a blank true-colour canvas from scratch (great for charts/badges).
$canvas = imagecreatetruecolor(200, 80); // 200 x 80 pixels
$blue = imagecolorallocate($canvas, 30, 90, 220); // pick an RGB colour
imagefill($canvas, 0, 0, $blue); // flood-fill it blue
imagejpeg($img, 'copy.jpg'); // save the loaded photo back out
imagepng($canvas, 'badge.png'); // save the blank canvas as PNG
// ALWAYS free the memory - GD images are not garbage-collected promptly.
imagedestroy($img);
imagedestroy($canvas);
echo "Saved copy.jpg and badge.png\n";
?>Notice the imagedestroy() calls at the end. GD resources are not cleaned up promptly by PHP, so on a busy server you must free each one the moment you're done or memory piles up.
3️⃣ Resizing & Thumbnails (the main event)
Serving a 4000×3000 original to a phone wastes bandwidth and slows your page. The fix is to resize. The one rule that matters: preserve the aspect ratio. Work out a single scale ratio and apply it to both width and height — apply two different ratios and the image squashes. To fit inside a box use min(); capping at 1.0 stops you upscaling a small image (which only blurs it).
<?php
// So this runs anywhere: paint a 1200x900 stand-in for the photo you would
// really be processing. Delete these five lines once you have a photo.jpg.
if (!file_exists('photo.jpg')) {
$demo = imagecreatetruecolor(1200, 900);
imagefill($demo, 0, 0, imagecolorallocate($demo, 120, 160, 200));
imagejpeg($demo, 'photo.jpg');
imagedestroy($demo);
}
// Resize while PRESERVING ASPECT RATIO. The trick is one scale ratio applied to
// BOTH width and height - never two different ratios, or the image squashes.
function resizeFit(string $src, string $dest, int $maxW, int $maxH, int $quality = 80): void
{
[$w, $h] = getimagesize($src); // original size, 1200 x 900 here
// Scale to FIT inside the box. min() keeps both sides within bounds.
// The 1.0 cap means we shrink but never blow a small image up (which blurs it).
$ratio = min($maxW / $w, $maxH / $h, 1.0); // min(0.667, 0.667, 1) = 0.667
$newW = (int) round($w * $ratio); // 800
$newH = (int) round($h * $ratio); // 600 — the box, exactly
$source = imagecreatefromjpeg($src);
$canvas = imagecreatetruecolor($newW, $newH);
// imagecopyresampled does smooth bilinear resampling - far better quality
// than imagecopyresized, which just drops pixels and looks jagged.
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $newW, $newH, $w, $h);
imagejpeg($canvas, $dest, $quality);
imagedestroy($source);
imagedestroy($canvas);
}
resizeFit('photo.jpg', 'photo-800.jpg', 800, 600);
echo "Resized 4000x3000 -> 800x600 (aspect ratio kept)\n";
?>Always reach for imagecopyresampled(), not imagecopyresized() — the first smooths pixels for a clean result; the second just throws pixels away and looks jagged.
A thumbnail is usually a fixed square, which means you can't just fit — you'd get empty bars. Instead cover the box (use max() so it fills) and crop the overflow from the centre. That's "crop-to-fill", the look every avatar uses.
<?php
// So this runs anywhere: paint a 1200x900 stand-in for the photo you would
// really be processing. Delete these five lines once you have a photo.jpg.
if (!file_exists('photo.jpg')) {
$demo = imagecreatetruecolor(1200, 900);
imagefill($demo, 0, 0, imagecolorallocate($demo, 120, 160, 200));
imagejpeg($demo, 'photo.jpg');
imagedestroy($demo);
}
// A square thumbnail needs CROP-TO-FILL: scale so the image COVERS the box
// (use max(), not min()), then chop the overflow off centre.
function thumbnail(string $src, string $dest, int $size, int $quality = 80): void
{
[$w, $h] = getimagesize($src); // e.g. 4000 x 3000
$ratio = max($size / $w, $size / $h); // cover, so the box is filled
$scaledW = (int) round($w * $ratio);
$scaledH = (int) round($h * $ratio);
// Centre the crop: half the overflow on each side.
$cropX = (int) round(($scaledW - $size) / 2);
$cropY = (int) round(($scaledH - $size) / 2);
$source = imagecreatefromjpeg($src);
$scaled = imagecreatetruecolor($scaledW, $scaledH);
imagecopyresampled($scaled, $source, 0, 0, 0, 0, $scaledW, $scaledH, $w, $h);
$thumb = imagecreatetruecolor($size, $size);
// imagecopy lifts the centred $size x $size square out of the scaled image.
imagecopy($thumb, $scaled, 0, 0, $cropX, $cropY, $size, $size);
imagejpeg($thumb, $dest, $quality);
imagedestroy($source);
imagedestroy($scaled);
imagedestroy($thumb);
}
thumbnail('photo.jpg', 'thumb-150.jpg', 150);
echo "Created a 150x150 centre-cropped thumbnail\n";
?>4️⃣ Watermarks
A watermark stamps text or a logo onto an image — useful for branding gallery photos. The key is the alpha (transparency) channel: imagecolorallocatealpha() takes a 4th value from 0 (solid) to 127 (invisible), so a value like 75 gives a soft, see-through mark that doesn't hide the photo.
<?php
// So this runs anywhere: paint a 1200x900 stand-in for the photo you would
// really be processing. Delete these five lines once you have a photo.jpg.
if (!file_exists('photo.jpg')) {
$demo = imagecreatetruecolor(1200, 900);
imagefill($demo, 0, 0, imagecolorallocate($demo, 120, 160, 200));
imagejpeg($demo, 'photo.jpg');
imagedestroy($demo);
}
// Stamp a semi-transparent text watermark onto the bottom-right corner.
$img = imagecreatefromjpeg('photo.jpg');
$w = imagesx($img); // width of the loaded image
$h = imagesy($img); // height of the loaded image
// The 4th argument (127) of imagecolorallocatealpha is the alpha: 0 = solid,
// 127 = fully transparent. 75 gives a soft, see-through white.
$white = imagecolorallocatealpha($img, 255, 255, 255, 75);
$text = '(c) My Site';
$font = 5; // built-in GD font (1-5)
$tx = $w - imagefontwidth($font) * strlen($text) - 12; // 12px from the right
$ty = $h - imagefontheight($font) - 10; // 10px from the bottom
imagestring($img, $font, $tx, $ty, $text, $white); // draw the watermark
imagejpeg($img, 'watermarked.jpg', 90);
imagedestroy($img);
echo "Watermarked photo saved\n";
?>5️⃣ Format Conversion & Compression
Converting to WebP is the single biggest page-speed win you can make: it's typically 25-35% smaller than JPEG and 80%+ smaller than PNG at the same visual quality. The pattern is "load in any format, save in another". The save functions also take a quality value (0-100) — your compression dial. 80 is the sweet spot: near-perfect to the eye, a fraction of the bytes.
<?php
// So this runs anywhere: paint a stand-in PNG to convert.
if (!file_exists('photo.png')) {
$demo = imagecreatetruecolor(600, 400);
imagefill($demo, 0, 0, imagecolorallocate($demo, 200, 60, 90));
imagepng($demo, 'photo.png');
imagedestroy($demo);
}
// Convert any supported image to WebP - typically 25-35% smaller than JPEG and
// 80%+ smaller than PNG, with the same visual quality. Huge for page speed.
// getimagesize() returns the type code so you can pick the right loader.
$type = getimagesize('photo.png')[2]; // e.g. IMAGETYPE_PNG (3)
$img = match ($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg('photo.png'),
IMAGETYPE_PNG => imagecreatefrompng('photo.png'),
IMAGETYPE_GIF => imagecreatefromgif('photo.png'),
default => throw new RuntimeException("Unsupported type: {$type}"),
};
// The 3rd argument is quality, 0-100. 80 is a great default - near-lossless
// to the eye but a fraction of the file size.
imagewebp($img, 'photo.webp', 80);
imagedestroy($img);
$before = filesize('photo.png');
$after = filesize('photo.webp');
// The exact byte counts depend on the libwebp your PHP was built against, so
// compare rather than quote a number you would then have to keep true.
echo "WebP smaller than the PNG? " . ($after < $before ? "yes" : "no") . "\n";
?>6️⃣ EXIF Orientation & Metadata
EXIF is hidden data phones bake into a photo. Two parts matter. The Orientation flag records how the phone was held instead of rotating the pixels — so an untouched upload often appears sideways until you imagerotate() to compensate. The rest can include GPS coordinates and the camera model — a privacy leak if you publish the file as-is. The good news: re-saving through GD writes a clean file with no EXIF, so a load-process-save cycle fixes the rotation and strips the metadata in one go.
<?php
// Phone photos carry EXIF data: an "Orientation" flag, plus GPS coordinates and
// the camera model. Two jobs: ROTATE so the photo looks right, and STRIP the
// metadata so you do not leak where the user was standing.
// So this runs anywhere: a stand-in with no EXIF at all, which is the
// "orientation 1" case. A real phone photo usually reports 6 or 8.
if (!file_exists('phone.jpg')) {
$demo = imagecreatetruecolor(800, 600);
imagefill($demo, 0, 0, imagecolorallocate($demo, 90, 140, 110));
imagejpeg($demo, 'phone.jpg');
imagedestroy($demo);
}
$img = imagecreatefromjpeg('phone.jpg');
// exif_read_data needs the EXIF extension; @ silences the notice if there's none.
$exif = @exif_read_data('phone.jpg');
$orientation = $exif['Orientation'] ?? 1; // 1 = already upright
// The orientation flag tells you how the camera was held. Rotate to compensate.
$img = match ($orientation) {
3 => imagerotate($img, 180, 0), // upside down
6 => imagerotate($img, -90, 0), // rotated 90 clockwise
8 => imagerotate($img, 90, 0), // rotated 90 anticlockwise
default => $img, // 1, or unknown: leave it
};
// Re-saving through GD writes a CLEAN file with NO EXIF - the GPS/metadata is
// gone automatically, and the pixels are already rotated correctly.
imagejpeg($img, 'phone-fixed.jpg', 85);
imagedestroy($img);
echo "Rotated for orientation {$orientation} and stripped EXIF\n";
?>Now you try. The script below has the aspect-ratio maths blanked out. Fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish the aspect-ratio maths, then run it.
// A 1600x900 source must fit inside a 400x400 box without distortion.
$w = 1600;
$h = 900;
$maxW = 400;
$maxH = 400;
// 1) Pick the SMALLER scale so both sides fit, and never upscale.
$ratio = ___; // 👉 use min($maxW / $w, $maxH / $h, 1.0)
// 2) Multiply both sides by the SAME ratio and round to whole pixels.
$newW = (int) round($w * ___); // 👉 multiply by $ratio
$newH = (int) round($h * ___); // 👉 multiply by $ratio (same ratio!)
echo "New size: {$newW} x {$newH}\n";
// ✅ Expected output:
// New size: 400 x 225
?>One more. This one converts a JPEG to WebP — fill in the two missing GD function names.
<?php
// 🎯 YOUR TURN — convert a JPEG to WebP at quality 80.
// The loader and the save call each need ONE function name filled in.
$img = ___('photo.jpg'); // 👉 load a JPEG: imagecreatefromjpeg
___($img, 'photo.webp', 80); // 👉 save as WebP: imagewebp (3rd arg = quality)
imagedestroy($img); // free the memory
echo "Converted photo.jpg to photo.webp\n";
// ✅ Expected output:
// Converted photo.jpg to photo.webp
?>7️⃣ The Intervention Image Library
Writing raw GD by hand is verbose and easy to get wrong. Intervention Image is the most popular PHP image library: install it with composer require intervention/image and you get one fluent, chainable API that sits on top of either GD or Imagick. It reads EXIF orientation automatically, and methods like cover(), scaleDown() and toWebp() replace a dozen lines of GD each.
<?php
// Doing all of the above by hand is verbose. The Intervention Image library
// wraps GD/Imagick in one fluent API: install with composer require intervention/image
require 'vendor/autoload.php';
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
$manager = new ImageManager(new Driver()); // or Imagick\Driver for sharper output
$image = $manager->read('photo.jpg'); // reads EXIF orientation for you
// Chain operations - each returns the image so you can keep going.
$image->cover(300, 300) // crop-to-fill a 300x300 square
->toWebp(80) // encode as WebP, quality 80
->save('avatar.webp'); // write it to disk
// scaleDown() is the safe resize: fits inside the box and NEVER upscales.
$manager->read('photo.jpg')
->scaleDown(width: 800)
->save('photo-800.jpg');
echo "Intervention Image: avatar + 800px copy written\n";
?>For real projects, reach for Intervention first — you only drop down to raw GD when you need something the library doesn't expose.
Common Errors (and the fix)
- "Allowed memory size of … bytes exhausted" on a big photo — GD loads every pixel into RAM (~4 bytes each), so a 6000×4000 image needs ~96 MB before resizing. Check dimensions with getimagesize() and reject oversized uploads, raise memory_limit for the script, and imagedestroy() each resource the moment you're done.
- Uploaded photos appear sideways — you didn't handle the EXIF Orientation flag. Read exif_read_data()['Orientation'] and imagerotate() to compensate. Re-saving the rotated image also strips the EXIF, which removes any GPS data the photo carried.
- "Call to undefined function imagecreatefromjpeg()" — the GD extension isn't installed or enabled. Install it (apt install php-gd on Debian/Ubuntu) and confirm with extension_loaded('gd'). For WebP you also need GD built with WebP support.
- The image looks squashed or stretched — you set the new width and height independently instead of scaling by one shared ratio. Compute $ratio once with min() (fit) or max() (cover) and multiply both sides by it.
Pro Tips
- 💡 Process in the background. Resizing on the request that handles the upload makes the user wait — push the job to a queue and return immediately for anything heavy.
- 💡 Generate a responsive set once. Create 150 / 320 / 800 / 1200px WebP versions at upload time and serve them with the HTML srcset attribute so each device downloads only what it needs.
- 💡 Use imagecopyresampled, never imagecopyresized. The quality difference is night and day for almost identical code.
📋 Quick Reference — Image Processing
| Function | Example | What It Does |
|---|---|---|
| getimagesize() | [$w,$h,$t] = getimagesize($f) | Read size/type from the header (cheap) |
| imagecreatefromjpeg() | $img = imagecreatefromjpeg($f) | Load a file into a GD resource |
| imagecreatetruecolor() | imagecreatetruecolor(200,80) | Make a blank canvas |
| imagecopyresampled() | imagecopyresampled($dst,$src,…) | Resize with smooth resampling |
| imagewebp() | imagewebp($img,$dest,80) | Save as WebP at a quality |
| imagerotate() | imagerotate($img,-90,0) | Rotate (for EXIF orientation) |
| imagedestroy() | imagedestroy($img) | Free the resource's memory |
Mini-Challenge: Make an Avatar
No code is filled in this time — just a brief and an outline. Write the function yourself, run it on a real photo with php avatar.php, then check the result against the expected output in the comments. This is the crop-to-fill plus WebP combo you'll use for every profile picture.
<?php
// 🎯 MINI-CHALLENGE: a "make an avatar" helper.
// No code is filled in — work from the steps, then run it on a real photo.
//
// Write a function makeAvatar(string $src, string $dest, int $size = 256) that:
// 1. Reads the source size with getimagesize($src).
// 2. Scales to COVER a $size x $size box: $ratio = max($size/$w, $size/$h).
// 3. Centre-crops the overflow with imagecopy() (see the thumbnail example).
// 4. Saves the result as WebP at quality 80 with imagewebp($thumb, $dest, 80).
// 5. Calls imagedestroy() on every GD resource you created.
// Then call it: makeAvatar('photo.jpg', 'avatar.webp');
//
// ✅ Expected output (example):
// Avatar written: avatar.webp (256x256)
// your code here
?>🎉 Lesson Complete!
- ✅ GD ships with PHP and covers most needs; Imagick is sharper but a separate install
- ✅ Load files with imagecreatefrom…() and create canvases with imagecreatetruecolor()
- ✅ Resize by one shared ratio (min to fit), and crop-to-fill (max) for square thumbnails
- ✅ Watermark with an alpha colour, and convert to WebP at quality ~80 for big size savings
- ✅ Handle EXIF: rotate by the orientation flag, and re-save to strip GPS/metadata
- ✅ Always imagedestroy() resources, and reach for Intervention Image in real projects
Practice quiz
Which image library is bundled with almost every PHP install and used for most common work?
- Imagick
- Intervention
- GD
- ImageMagick CLI
Answer: GD. GD ships with nearly every PHP install and covers JPEG, PNG, GIF and WebP; Imagick is a separate extension.
Why call getimagesize() before loading an image into memory?
- It reads only the file header, so you learn the real type and dimensions cheaply before loading megabytes into RAM
- It resizes the image automatically
- It converts the image to WebP
- It strips EXIF data
Answer: It reads only the file header, so you learn the real type and dimensions cheaply before loading megabytes into RAM. getimagesize() reads just the header, returning width, height, and a type code without decompressing the whole file.
To resize an image without squashing it, how do you compute the new dimensions?
- Set the new width and height independently to your targets
- Use two different ratios for width and height
- Always crop to a square first
- Compute one scale ratio and multiply BOTH width and height by it
Answer: Compute one scale ratio and multiply BOTH width and height by it. Preserve the aspect ratio by applying a single ratio to both sides; two different ratios distort the image.
For a 1600x900 source fitting inside a 400x400 box (no upscaling), what is the resulting size?
- 400x400
- 400x225
- 711x400
- 400x300
Answer: 400x225. ratio = min(400/1600, 400/900, 1.0) = 0.25, so 1600x900 becomes 400x225.
When building a square thumbnail (crop-to-fill), which function picks the scale ratio?
- max(), so the image COVERS the box, then crop the overflow from the centre
- min(), to fit inside the box
- round(), to the nearest pixel
- abs(), to avoid negatives
Answer: max(), so the image COVERS the box, then crop the overflow from the centre. Crop-to-fill uses max() so the box is fully covered, then the centred overflow is cropped off.
Why use imagecopyresampled() instead of imagecopyresized()?
- It is the only one that works with WebP
- It is faster but lower quality
- It does smooth bilinear resampling for a clean result, while imagecopyresized just drops pixels and looks jagged
- It automatically strips EXIF
Answer: It does smooth bilinear resampling for a clean result, while imagecopyresized just drops pixels and looks jagged. imagecopyresampled smooths pixels; imagecopyresized throws pixels away and produces a jagged result.
In imagecolorallocatealpha(), what does an alpha value of 127 mean?
- Fully solid
- Fully transparent (invisible)
- Pure white
- 50% grey
Answer: Fully transparent (invisible). Alpha runs 0 (solid) to 127 (fully transparent); around 75 gives a soft, see-through watermark.
Which GD function saves an image as WebP, and what is the third argument?
- imagejpeg($img, $dest, 80) — the third argument is the width
- imagepng($img, $dest, 80) — the third argument is compression level
- imagecreatefromwebp($dest) — there is no quality argument
- imagewebp($img, $dest, 80) — the third argument is the quality (0–100)
Answer: imagewebp($img, $dest, 80) — the third argument is the quality (0–100). imagewebp() saves WebP; the third argument is the 0–100 quality dial, with 80 a great default.
Re-saving a phone photo through GD has what useful side effect on EXIF data?
- It doubles the GPS precision
- It writes a clean file with NO EXIF, automatically stripping GPS/metadata
- It rotates the image 180 degrees every time
- It embeds the camera model into the filename
Answer: It writes a clean file with NO EXIF, automatically stripping GPS/metadata. A load-process-save cycle through GD writes a clean file with no EXIF, removing the privacy-leaking metadata.
Why must you call imagedestroy() on GD resources when you're done?
- To save the file to disk
- It is required before every imagejpeg() call
- GD resources are not garbage-collected promptly, so freeing each one prevents memory piling up on a busy server
- It converts the image to grayscale
Answer: GD resources are not garbage-collected promptly, so freeing each one prevents memory piling up on a busy server. GD images aren't cleaned up promptly by PHP; free each resource the moment you're done to avoid memory exhaustion.
Continue this course
- Previous: Building Email Systems: SMTP, PHPMailer, DKIM, SPF
- Next: Working with PDF Generation & File Exporting — Generate PDFs, invoices, and reports with TCPDF and dompdf
- Quick reference: PHP cheat sheet
Frequently asked questions
Should I use GD or Imagick?
Use GD for the common case: it is bundled with almost every PHP install, needs no extra setup, and handles JPEG, PNG, GIF and WebP well. Reach for Imagick when you need sharper resampling, professional colour handling, formats GD doesn't support (TIFF, PSD, PDF, HEIC), or operations like multi-page handling. Imagick is a separate extension that wraps the ImageMagick binary, so it must be installed and enabled on the server. Many teams skip the choice entirely and use the Intervention Image library, which sits on top of either driver and gives one clean API.
Why is my resized image squashed or stretched?
You applied two different scale factors to width and height. To preserve the aspect ratio you must compute one ratio and multiply BOTH sides by it. Use min($maxW/$w, $maxH/$h) to fit inside a box, or max(...) to fill (cover) a box, then crop the overflow. Never set the new width and height independently to your target numbers unless you genuinely want distortion.
Why does PHP run out of memory on large images?
GD decompresses an image into raw pixels in RAM: every pixel costs roughly 4 bytes, so a 6000x4000 photo needs about 96 MB before you have even resized it, far more than the typical 128 MB memory_limit once you also hold the destination canvas. Check the dimensions with getimagesize() (which reads only the header) and reject images bigger than you support, raise memory_limit for the resize script, free each resource with imagedestroy() as soon as you are done, and consider Imagick, which can stream large files instead of loading them whole.
Do I need to do anything about EXIF data?
Yes, for two reasons. First, phone photos store an Orientation flag instead of physically rotating the pixels, so an untouched upload often appears sideways; read $exif['Orientation'] and imagerotate() to compensate. Second, EXIF can contain GPS coordinates and the camera model, which is a privacy leak if you publish the file as-is. Re-saving through GD writes a clean file with no EXIF, which strips the metadata for free, so a load-process-save cycle solves both problems at once.
What quality value should I use for WebP and JPEG?
Quality is a 0-100 dial trading file size against visual fidelity. 80 is the sweet spot for photos: nearly indistinguishable from the original to the eye while cutting the file size dramatically. Drop to 60-70 for thumbnails where small size matters more than detail, and push to 90+ only for hero images or when re-compression artefacts would show. WebP at quality 80 is typically 25-35% smaller than the equivalent JPEG, which is why it is the default modern format for the web.