instagrapi

Upload a photo to Instagram from Python with instagrapi

Updated

Setup

Uploads require an authenticated client, and they’re the operation Instagram scrutinises hardest. Don’t log in cold on every run — Instagram flags fresh device fingerprints fast, and photo_upload will be the first thing to trip a challenge_required if the session is brittle. Persist the session and reuse it across runs so the same device fingerprint posts every time.

from instagrapi import Client

cl = Client()
try:
    cl.load_settings("session.json")
    cl.login("USERNAME", "PASSWORD")
except FileNotFoundError:
    cl.login("USERNAME", "PASSWORD")
    cl.dump_settings("session.json")

That’s the minimum. For the full pattern — Redis-backed sessions, multi-account orchestration, surviving cookie invalidation — see /guides/instagrapi-session-persistence. The rest of this guide assumes cl is a logged-in client whose session you’ve already saved.

Single photo upload

The simplest call is photo_upload(path, caption). It accepts a pathlib.Path (or string) pointing to a JPEG or PNG, runs the multi-step Instagram upload protocol — config request, photo upload, configure media — and returns a Media Pydantic model with the new post’s pk, id, code, taken_at, and full media metadata.

from pathlib import Path
from instagrapi import Client

cl = Client()
cl.load_settings("session.json")
cl.login("USERNAME", "PASSWORD")

media = cl.photo_upload(
    Path("./photo.jpg"),
    caption="Sunset over Lisbon #travel",
)
print(media.code)

media.code is the shortcode you can drop into a https://instagram.com/p/<code>/ URL to open the post in a browser. Keep media.pk around if you need to fetch comments, likers, or insights later — that’s the canonical numeric identifier and what every other instagrapi method expects. The call is synchronous and typically takes 2–6 seconds depending on file size and network. If it returns at all, the post exists; instagrapi raises on every documented failure mode rather than returning a partial Media, so you don’t need to defensive-check the return value.

Adding a location and user tags

Captions go a long way, but locations and user tags are what Instagram’s recommendation system actually keys off of. Both are first-class arguments on photo_upload. Locations take a Location model with a name and coordinates; user tags take a list of Usertag models, each with a target user and a (x, y) position normalised to the [0, 1] range — (0.5, 0.5) is dead-centre.

You don’t have to look up Instagram’s internal location IDs; passing name + lat/lng is enough, and Instagram resolves it server-side. For user tags, you need a UserShort for each tagged account — user_short_gql(username) is the cheapest way to fetch one.

from instagrapi.types import Location, Usertag

loc = Location(name="Lisbon, Portugal", lat=38.7223, lng=-9.1393)
tags = [Usertag(user=cl.user_short_gql("instagram"), x=0.5, y=0.5)]

media = cl.photo_upload(
    Path("./photo.jpg"),
    caption="With friends",
    location=loc,
    usertags=tags,
)

Tag positions outside [0, 1] are silently clamped; expect tags at the image boundary to render slightly inside the frame. Tagging private accounts works but the tag is hidden from non-followers, and tagging accounts that have blocked you returns a feedback_required rather than a clean error — sanity-check the relationship before tagging at scale. The maximum is 20 user tags per photo, matching the Instagram app’s own limit.

Carousels (album_upload)

Instagram allows 2 to 10 media items per carousel post. instagrapi exposes this as album_upload, which takes a list of paths and runs the configure step once with all uploaded children attached. Caption, location, and user tags work the same way they do on photo_upload — they apply to the carousel as a whole, not to individual slides.

media = cl.album_upload(
    paths=[Path("./a.jpg"), Path("./b.jpg"), Path("./c.jpg")],
    caption="Trip recap",
)

A few practical notes. First, all photos in a carousel are cropped to the same aspect ratio — whichever ratio the first photo defines. Mixing portrait and landscape inputs gives ugly results; pre-resize them to a consistent shape before passing the list. Second, album_upload also accepts video paths, so you can mix photos and videos in one carousel; instagrapi dispatches each child to the right upload endpoint and assembles them server-side. Third, partial failures are not retried — if the third upload fails after two succeed, the whole call raises and you start over from scratch. There’s no resume primitive, so for big carousels on flaky networks, upload the children to a staging account first or accept the occasional re-do.

Pre-resizing with Pillow

Always resize before upload. Instagram will accept oversized files, but it re-encodes them server-side with no quality guarantees, and files much larger than 8MB occasionally fail outright with no useful error. The two sizes you actually want are 1080×1080 (square) and 1080×1350 (4:5 portrait) — anything larger is wasted bytes, anything smaller looks soft on retina screens.

Pillow’s thumbnail() resizes in place and preserves the aspect ratio, so a horizontal source becomes 1080×something-shorter rather than a stretched square. Quality 92 is the sweet spot for JPEG: visually indistinguishable from quality 100, roughly half the bytes.

from PIL import Image

img = Image.open("./input.jpg")
img.thumbnail((1080, 1080))
img.save("./photo.jpg", "JPEG", quality=92)

If you need an exact shape (square crop, 4:5 crop), do that explicitly with img.crop() before thumbnail() — relying on Instagram’s auto-crop is a recipe for cutting off heads. PNGs with transparency get flattened against black on Instagram’s side; if you care about the background colour, paste the PNG onto a white Image.new("RGB", ...) canvas and save as JPEG before upload.

Common upload errors

Three failure modes account for almost everything you’ll see in production.

feedback_required is Instagram’s generic throttle response. It means your account is posting too fast, your IP is suspicious, or your action mix is too automated-looking. There is no retry that fixes it in the moment — back off for at least an hour, ideally longer for a new account, and add jitter between posts going forward. Don’t loop-retry; it makes the throttle worse.

MediaNotFound right after a successful upload is a race condition, not a real failure. Instagram returns the new media object before its database has fully replicated, so a follow-up media_info_by_pk(media.pk) can 404. Sleep 2–3 seconds before any post-upload fetch, or just trust the Media returned from photo_upload — it already has everything you need.

File size errors usually surface as opaque 500s or PhotoNotUpload. Resize first; the Pillow snippet above eliminates this entire class of problem. If you’re still seeing them after resizing, check the source format — HEIC and TIFF files sometimes survive a .jpg rename without actually being JPEG, and Instagram’s upload endpoint validates the file header, not the extension.

Wrapping up

Photo upload is the easy half of write-side automation — Instagram’s video and Reels pipelines have more moving parts and stricter encoding requirements, and that’s where most posting bots actually break. The pattern stays the same though: persist the session, pre-resize the media, handle feedback_required with backoff rather than retries, and trust the Media you get back instead of refetching it. If you’re building a posting tool, the next things worth reading are /guides/instagrapi-download-stories-python for the read-side counterpart and /guides/instagram-scraper-python for the broader scraping context that usually precedes posting workflows.

Related guides

Frequently asked

What image formats does instagrapi accept?

JPEG and PNG; non-square images are auto-cropped on Instagram's side. Recommended size 1080×1080 or 1080×1350 for portrait. Files larger than 8MB sometimes fail — resize before upload.

Can I schedule a post for later?

instagrapi posts immediately. Scheduling is your application's job: store the request and call photo_upload at the scheduled time.

Why does my upload return MediaNotFound right after success?

Instagram processes uploads asynchronously. Polling media_info_by_pk immediately can hit a race; wait 2–3 seconds before fetching the post you just created.

Can I upload a carousel of multiple photos?

Yes. Use album_upload with a list of paths; instagrapi handles the carousel orchestration.

Skip the infra?

Managed Instagram API — same endpoints, sessions and proxies handled.

Try HikerAPI → Full comparison
More from the team