How to Rotate an Image
Rotating an image sounds trivial — until you hit EXIF orientation bugs, transparent-corner artifacts, or unexpected canvas clipping. This guide explains the rotation model, covers the most common angles (90°, 180°, 270°) as well as arbitrary-angle rotation, and provides working implementations in JavaScript, Python, Go, and PHP.
The Rotation Model
Two types of rotation matter in practice:
| Type | Description | Output size |
|---|---|---|
| Orthogonal | 90 °, 180 °, 270 ° — lossless pixel rearrangement | Swaps width/height for 90 °/270 ° |
| Arbitrary | Any angle — requires resampling | Usually larger (corners filled) |
Direction convention:
- Clockwise (CW): most UI tools and EXIF use this.
- Counter-clockwise (CCW): some math libraries default to this.
- 90 ° CW = 270 ° CCW. Always check which direction the API expects.
Canvas size after rotation:
- 90 °/270 °: new width = old height, new height = old width.
- Arbitrary angle: new dimensions = bounding box of the rotated original.
Original 400 × 300 rotated 90 ° CW:
┌──────────────┐ ┌────────────────────┐
│ │ │ │
│ 400 × 300 │ → │ 300 × 400 │
│ │ │ │
└──────────────┘ └────────────────────┘
EXIF Orientation — The Hidden Gotcha
Most smartphone cameras store photos in landscape orientation (sensor default) and embed an EXIF orientation tag (values 1–8) telling viewers to rotate the image for display. Many libraries do not apply this tag automatically.
If a photo appears sideways after processing, the fix is to strip or apply EXIF orientation before (or during) rotation:
Raw JPEG bytes: landscape (3024 × 4032)
EXIF tag 6 : "rotate 90 ° CW for display"
Browser img : displays correctly (auto-rotates)
Canvas : shows the raw landscape — sideways!
Fix: use a library option like Sharp's rotate() with no argument (auto-applies EXIF), or Pillow's ImageOps.exif_transpose().
JavaScript
Browser: Canvas API
/**
* Rotate an image by a multiple of 90 ° using the Canvas API.
* @param {HTMLImageElement} img - loaded <img> element
* @param {number} angle - 90 | 180 | 270 (degrees, clockwise)
* @returns {Promise<Blob>} - rotated image as PNG Blob
*/
function rotateImage(img, angle) {
const rad = (angle * Math.PI) / 180;
const sin = Math.abs(Math.sin(rad));
const cos = Math.abs(Math.cos(rad));
const srcW = img.naturalWidth;
const srcH = img.naturalHeight;
// New canvas dimensions (bounding box of rotated image)
const dstW = Math.round(srcW * cos + srcH * sin);
const dstH = Math.round(srcW * sin + srcH * cos);
const canvas = document.createElement("canvas");
canvas.width = dstW;
canvas.height = dstH;
const ctx = canvas.getContext("2d");
ctx.translate(dstW / 2, dstH / 2); // move origin to centre
ctx.rotate(rad); // rotate (Canvas uses CW for positive angles)
ctx.drawImage(img, -srcW / 2, -srcH / 2);
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}
// Usage
const img = document.querySelector("img");
img.onload = async () => {
const blob = await rotateImage(img, 90);
const url = URL.createObjectURL(blob);
document.querySelector("#preview").src = url;
};
Node.js: Sharp
Sharp makes orthogonal rotation one line, and automatically applies EXIF orientation when you call rotate() with no argument.
import sharp from "sharp";
// Auto-apply EXIF orientation (fix sideways phone photos)
await sharp("input.jpg")
.rotate() // reads and strips EXIF orientation
.toFile("fixed.jpg");
// Rotate 90 ° clockwise
await sharp("input.jpg")
.rotate(90)
.toFile("rotated-90cw.jpg");
// Rotate 45 ° — corners filled with white background
await sharp("input.jpg")
.rotate(45, { background: { r: 255, g: 255, b: 255, alpha: 1 } })
.toFile("rotated-45.jpg");
// Rotate and keep transparency (PNG only)
await sharp("input.png")
.rotate(45, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile("rotated-45-transparent.png");
Python
Pillow's rotate() method defaults to counter-clockwise. Pass expand=True to resize the canvas so no pixels are clipped.
from PIL import Image, ImageOps
def rotate_image(
input_path: str,
output_path: str,
angle: float,
expand: bool = True,
fill_color: tuple = (255, 255, 255),
) -> None:
"""
Rotate an image by `angle` degrees counter-clockwise.
Set expand=True to grow the canvas and avoid clipping.
"""
with Image.open(input_path) as img:
# Fix EXIF orientation before rotating
img = ImageOps.exif_transpose(img)
rotated = img.rotate(
angle,
expand=expand,
fillcolor=fill_color, # colour for empty corners
resample=Image.BICUBIC, # bicubic for smooth arbitrary angles
)
rotated.save(output_path)
# Rotate 90 ° clockwise (= -90 ° in Pillow's convention)
rotate_image("input.jpg", "rotated-90cw.jpg", angle=-90)
# Rotate 90 ° counter-clockwise
rotate_image("input.jpg", "rotated-90ccw.jpg", angle=90)
# Rotate 180 °
rotate_image("input.jpg", "rotated-180.jpg", angle=180)
# Rotate 45 ° with transparent corners (PNG only)
def rotate_transparent(input_path: str, output_path: str, angle: float) -> None:
with Image.open(input_path) as img:
img = img.convert("RGBA")
rotated = img.rotate(angle, expand=True, resample=Image.BICUBIC)
rotated.save(output_path) # must be PNG to preserve alpha
Pillow direction cheat-sheet:
| You want | Pillow angle |
|---|---|
| 90 ° CW | -90 |
| 90 ° CCW | +90 |
| 180 ° | 180 |
| 270 ° CW | -270 or +90 |
Go
The standard library has no built-in rotation. The cleanest approach is to draw onto a new canvas with a transformed coordinate system using golang.org/x/image/draw.
package main
import (
"image"
"image/jpeg"
"math"
"os"
"golang.org/x/image/draw"
)
// Rotate90CW returns a new image rotated 90 ° clockwise.
func Rotate90CW(src image.Image) *image.RGBA {
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
// Output dimensions are swapped
dst := image.NewRGBA(image.Rect(0, 0, srcH, srcW))
for y := 0; y < srcH; y++ {
for x := 0; x < srcW; x++ {
// 90 ° CW: new position = (srcH-1-y, x)
dst.Set(srcH-1-y, x, src.At(x+bounds.Min.X, y+bounds.Min.Y))
}
}
return dst
}
// Rotate180 returns a new image rotated 180 °.
func Rotate180(src image.Image) *image.RGBA {
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
dst := image.NewRGBA(image.Rect(0, 0, srcW, srcH))
for y := 0; y < srcH; y++ {
for x := 0; x < srcW; x++ {
dst.Set(srcW-1-x, srcH-1-y, src.At(x+bounds.Min.X, y+bounds.Min.Y))
}
}
return dst
}
// RotateArbitrary rotates src by angle degrees (CW) around its centre.
// Empty corners are transparent.
func RotateArbitrary(src image.Image, angleDeg float64) *image.RGBA {
rad := angleDeg * math.Pi / 180.0
sin, cos := math.Sin(rad), math.Cos(rad)
bounds := src.Bounds()
srcW := float64(bounds.Dx())
srcH := float64(bounds.Dy())
// Bounding box of rotated image
dstW := int(math.Ceil(math.Abs(srcW*cos) + math.Abs(srcH*sin)))
dstH := int(math.Ceil(math.Abs(srcW*sin) + math.Abs(srcH*cos)))
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
// Centre offsets
cx, cy := srcW/2, srcH/2
ox, oy := float64(dstW)/2, float64(dstH)/2
for dy := 0; dy < dstH; dy++ {
for dx := 0; dx < dstW; dx++ {
// Inverse rotation: map dst pixel back to src
fx := float64(dx) - ox
fy := float64(dy) - oy
sx := fx*cos + fy*sin + cx
sy := -fx*sin + fy*cos + cy
if sx >= 0 && sx < srcW && sy >= 0 && sy < srcH {
dst.Set(dx, dy, src.At(int(sx)+bounds.Min.X, int(sy)+bounds.Min.Y))
}
}
}
return dst
}
func main() {
f, _ := os.Open("input.jpg")
defer f.Close()
src, _, _ := image.Decode(f)
rotated := Rotate90CW(src)
out, _ := os.Create("rotated-90cw.jpg")
defer out.Close()
jpeg.Encode(out, rotated, &jpeg.Options{Quality: 90})
}
For production use, add the
golang.org/x/image/drawpackage and apply bilinear interpolation (draw.BiLinear.Transform) to avoid aliasing on arbitrary angles.
PHP
GD
<?php
/**
* Rotate an image file and save the result.
*
* @param string $inputPath Path to source image.
* @param string $outputPath Path to save rotated image.
* @param float $angle Rotation angle in degrees (counter-clockwise for GD).
* @param array $bgColor RGB fill colour for empty corners.
*/
function rotateImage(
string $inputPath,
string $outputPath,
float $angle,
array $bgColor = [255, 255, 255]
): void {
$src = imagecreatefromstring(file_get_contents($inputPath));
if ($src === false) {
throw new RuntimeException("Cannot open: $inputPath");
}
// GD imagerotate uses counter-clockwise degrees.
// To rotate 90 ° CW, pass 270 (or -90).
$bg = imagecolorallocate($src, $bgColor[0], $bgColor[1], $bgColor[2]);
$dst = imagerotate($src, $angle, $bg);
if ($dst === false) {
imagedestroy($src);
throw new RuntimeException("Rotation failed.");
}
$ext = strtolower(pathinfo($outputPath, PATHINFO_EXTENSION));
match ($ext) {
'jpg', 'jpeg' => imagejpeg($dst, $outputPath, 90),
'png' => imagepng($dst, $outputPath),
'webp' => imagewebp($dst, $outputPath, 90),
default => throw new RuntimeException("Unsupported format: $ext"),
};
imagedestroy($src);
imagedestroy($dst);
}
// Rotate 90 ° clockwise (= 270 ° CCW in GD)
rotateImage('input.jpg', 'rotated-90cw.jpg', 270);
// Auto-apply EXIF orientation
function rotateByExif(string $inputPath, string $outputPath): void {
$exif = @exif_read_data($inputPath);
$orientation = $exif['Orientation'] ?? 1;
// Map EXIF orientation → GD angle (CCW)
$angleMap = [3 => 180, 6 => 270, 8 => 90];
$angle = $angleMap[$orientation] ?? 0;
if ($angle === 0) {
copy($inputPath, $outputPath);
return;
}
rotateImage($inputPath, $outputPath, $angle);
}
Imagick
<?php
$img = new Imagick('input.jpg');
// Auto-apply EXIF and strip metadata
$img->autoOrient();
$img->stripImage();
// Rotate 90 ° clockwise with white background
$img->rotateImage(new ImagickPixel('#FFFFFF'), 90);
$img->writeImage('rotated.jpg');
$img->destroy();
Imagick::rotateImage()takes degrees clockwise — opposite to GD's counter-clockwise convention.
Quick Reference
| Library | 90 ° CW | 90 ° CCW | EXIF auto-fix |
|---|---|---|---|
| Sharp (Node) | rotate(90) |
rotate(270) |
rotate() (no arg) |
| Pillow (Python) | rotate(-90, expand=True) |
rotate(90, expand=True) |
ImageOps.exif_transpose() |
| GD (PHP) | imagerotate($img, 270, $bg) |
imagerotate($img, 90, $bg) |
manual via exif_read_data |
| Imagick (PHP) | rotateImage(px, 90) |
rotateImage(px, 270) |
autoOrient() |
| Go stdlib | pixel-loop (Rotate90CW) |
pixel-loop | manual EXIF parse |
| Canvas (JS) | ctx.rotate(Math.PI/2) |
ctx.rotate(-Math.PI/2) |
exifr library |
Common Pitfalls
1. Forgetting expand=True in Pillow
Without expand=True, Pillow rotates within the original canvas size. Corners get clipped for 45 ° rotations, and 90 °/270 ° results look squashed.
2. GD vs Imagick direction mismatch
GD's imagerotate is counter-clockwise; Imagick's rotateImage is clockwise. If you switch between the two, your rotations will be mirrored.
3. EXIF orientation ignored by Canvas
The browser's <img> tag respects EXIF orientation; the Canvas API does not. Photo appears correct in <img> but sideways after drawImage. Fix: use the exifr library to read orientation and pre-rotate.
4. Arbitrary angle + JPEG = quality loss
Every JPEG encode degrades quality. Rotation at non-right-angles requires pixel resampling (further quality loss). For lossless 90°/180°/270° rotation of JPEGs, use jpegtran -rotate 90 (lossless JPEG transforms).
5. Canvas size not updated for 90 °/270 °
If you create a 400×300 canvas and rotate 90 °, you need a 300×400 canvas — not 400×300. Forgetting to swap width/height clips the image.
6. Transparent corners on JPEG output
JPEGs have no alpha channel. If you rotate 45 ° and output as JPEG, transparent corners become black. Use a solid fill colour, or output as PNG to preserve transparency.
FAQ
What's the difference between rotate and flip? Rotation turns the image around its centre (preserving content). Flip (horizontal mirror or vertical mirror) reflects the image — equivalent to 0 ° rotation with axis mirroring. They can be combined.
How do I fix sideways phone photos automatically?
Use EXIF auto-rotation: Sharp's rotate() (no argument), Pillow's ImageOps.exif_transpose(), or Imagick's autoOrient(). Always strip EXIF after applying to avoid double-rotation by other viewers.
Does rotating 90 ° change file size? For JPEG, slightly — the encoder may produce a marginally different file. For lossless formats (PNG, WebP lossless), the pixel count is the same so size stays very similar.
Can I rotate without re-encoding the JPEG?
Yes — for 90 °/180 °/270 ° only. Use jpegtran -rotate 90 -perfect for lossless JPEG rotation. This rearranges DCT blocks instead of decoding and re-encoding.
Why does my image look blurry after rotation?
Arbitrary-angle rotation requires resampling (each output pixel is interpolated from nearby source pixels). Use BICUBIC (Pillow) or BiLinear (Go) resampling for better quality than nearest-neighbour.
How do I rotate around a point other than the centre?
Translate the origin to your desired pivot point before rotating, then translate back. In Canvas: ctx.translate(pivotX, pivotY); ctx.rotate(angle); ctx.translate(-pivotX, -pivotY).