Instagram compresses every photo you upload. If you upload a 4032×3024 iPhone HEIC, Instagram converts it to JPG internally, downscales it, and applies its own compression — and the result is often muddy colors and soft edges. Uploading a pre-prepared JPG at exactly 1080×1350 gives Instagram nothing to resize, which means its compression artifacts are minimal.

TL;DR

  • Best dimensions: 1080×1350 (4:5 portrait — most screen real estate in feed)
  • Square: 1080×1080 (1:1 — safe for grid-first accounts)
  • Landscape: 1080×566 (1.91:1 — max width landscape)
  • Format: JPG at quality=92 (Instagram won't visibly degrade this)
  • File size target: Under 8MB (Instagram's upload limit is 30MB but under 8MB skips extra compression)

Instagram image specifications

FormatDimensionsAspect RatioUse Case
Portrait (feed)1080×1350px4:5Most visible in feed, recommended
Square (feed)1080×1080px1:1Grid consistency, profile preview
Landscape (feed)1080×566px1.91:1Wide/panorama shots
Stories/Reels1080×1920px9:16Full-screen vertical
Profile photo320×320px1:1Displayed 110×110px in feed

Python: HEIC to Instagram-ready JPG

pip install Pillow pyheif
# Linux: apt install libheif-dev
from PIL import Image, ImageOps
from pathlib import Path
import pyheif

IG_PORTRAIT = (1080, 1350)   # 4:5 — recommended
IG_SQUARE = (1080, 1080)     # 1:1
IG_LANDSCAPE = (1080, 566)   # 1.91:1
IG_STORY = (1080, 1920)      # 9:16

def load_image(path: Path) -> Image.Image:
    """Load JPG, PNG, or HEIC into a Pillow Image."""
    if path.suffix.lower() in (".heic", ".heif"):
        heif = pyheif.read(str(path))
        img = Image.frombytes(heif.mode, heif.size, heif.data, "raw", heif.mode, heif.stride)
    else:
        img = Image.open(path)
    return ImageOps.exif_transpose(img).convert("RGB")

def smart_crop(img: Image.Image, target: tuple) -> Image.Image:
    """Scale to fill target dimensions, then center-crop."""
    target_w, target_h = target
    src_ratio = img.width / img.height
    tgt_ratio = target_w / target_h

    if src_ratio > tgt_ratio:
        # Image is wider than target — scale by height, crop width
        new_h = target_h
        new_w = int(img.width * target_h / img.height)
    else:
        # Image is taller than target — scale by width, crop height
        new_w = target_w
        new_h = int(img.height * target_w / img.width)

    img = img.resize((new_w, new_h), Image.LANCZOS)

    # Center crop
    left = (new_w - target_w) // 2
    top = (new_h - target_h) // 2
    return img.crop((left, top, left + target_w, top + target_h))

def prepare_for_instagram(
    src: str,
    out: str,
    dimensions: tuple = IG_PORTRAIT,
    quality: int = 92,
) -> dict:
    img = load_image(Path(src))
    img = smart_crop(img, dimensions)
    img.save(out, "JPEG", quality=quality, optimize=True)
    size_kb = Path(out).stat().st_size // 1024
    return {"path": out, "dimensions": dimensions, "size_kb": size_kb}

# Single photo
result = prepare_for_instagram("photo.heic", "post.jpg", IG_PORTRAIT)
print(result)  # {'path': 'post.jpg', 'dimensions': (1080, 1350), 'size_kb': 312}

Batch: process a whole photo dump

from pathlib import Path
import concurrent.futures

def batch_instagram(
    input_dir: str,
    output_dir: str,
    dimensions: tuple = IG_PORTRAIT,
    workers: int = 4,
):
    src_dir = Path(input_dir)
    out_dir = Path(output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    exts = (".heic", ".heif", ".jpg", ".jpeg", ".png")
    photos = [p for p in src_dir.iterdir() if p.suffix.lower() in exts]
    print(f"Preparing {len(photos)} photos for Instagram ({dimensions[0]}×{dimensions[1]})")

    def process(p: Path) -> str:
        out = out_dir / (p.stem + ".jpg")
        r = prepare_for_instagram(str(p), str(out), dimensions)
        return f"{p.name} → {out.name} ({r['size_kb']}KB)"

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
        for r in concurrent.futures.as_completed([pool.submit(process, p) for p in photos]):
            print(r.result())

batch_instagram("./raw-photos", "./instagram-ready", IG_PORTRAIT)

ChangeThisFile API (HEIC → JPG)

# Convert HEIC to JPG via API, then resize locally
curl -X POST https://changethisfile.com/v1/convert \
  -H "Authorization: Bearer ctf_sk_your_key_here" \
  -F "file=@photo.heic" \
  -F "target=jpg" \
  --output photo.jpg
import requests
from PIL import Image
from pathlib import Path
import io

API_KEY = "ctf_sk_your_key_here"

def heic_to_instagram(heic_path: str, out_path: str, dims: tuple = (1080, 1350)) -> None:
    with open(heic_path, "rb") as f:
        resp = requests.post(
            "https://changethisfile.com/v1/convert",
            headers={"Authorization": f"Bearer {API_KEY}"},
            files={"file": f},
            data={"target": "jpg"},
            timeout=30,
        )
    resp.raise_for_status()
    img = Image.open(io.BytesIO(resp.content)).convert("RGB")
    img = smart_crop(img, dims)  # reuse function from above
    img.save(out_path, "JPEG", quality=92, optimize=True)

Edge cases and gotchas

  • Subject near the edge. Center crop cuts the edges symmetrically — bad for off-center subjects. For important photos, add a face/subject detection step before cropping. The simplest approach: crop manually using img.crop((left, top, right, bottom)) with hand-tuned coordinates.
  • Portrait photos in landscape orientation. If someone holds their phone sideways, the EXIF says rotate=90. Always call ImageOps.exif_transpose(img) before smart_crop — otherwise you'll resize a 4032×3024 landscape into a portrait crop of a rotated image.
  • Story vs feed dimensions. 9:16 (Stories) vs 4:5 (feed) are very different crops. Run the same photo through both dimensions if you're posting the same content to both.
  • Instagram's quality floor. Even a quality=92 JPG will be recompressed by Instagram. The recompression is most visible on fine textures (fabric, hair, sky gradients). Nothing you can do about this — it's Instagram's pipeline. quality=92 minimizes the damage.

Tips for consistent grid aesthetics

For brand accounts maintaining a consistent feed:

# Batch prepare with consistent white-border letterboxing instead of crop
def letterbox(img: Image.Image, target: tuple, bg_color=(255, 255, 255)) -> Image.Image:
    """Scale to fit inside target with white bars, no crop."""
    img.thumbnail(target, Image.LANCZOS)
    canvas = Image.new("RGB", target, bg_color)
    x = (target[0] - img.width) // 2
    y = (target[1] - img.height) // 2
    canvas.paste(img, (x, y))
    return canvas

# Use letterbox() instead of smart_crop() for product shots where no pixel can be cut

Pre-resizing to exactly 1080×1350 before uploading is the single highest-impact thing you can do for Instagram image quality. Instagram won't resize it and its compression is minimally destructive. API free tier for HEIC conversion if you're not running Python locally.