"The Uploaded File Could Not Be Moved to wp-content/uploads" — What to Check
The file reached the server and failed on the last step: copying it out of PHP's temp directory into the uploads folder. That folder exists — so this is about writing into it, not creating it. Check account disk quota first, then ownership.
Quota goes first because it's the one that lies to you. df -h can show 60% free while your hosting account is at its limit, and every permissions check you run afterwards will come back clean.
If your message names a folder WordPress couldn't create — "Unable to create directory wp-content/uploads/2026/08" — you're in the neighbouring problem, and that one has its own guide. The two look similar and fail at different moments, which is worth keeping straight before you start changing things.
Which stage this is
An upload passes through four hands:
- PHP receives the file and writes it to a temp directory. Failing here gives you "Missing a temporary folder".
- WordPress checks the type against its allowlist. Failing here gives you "not permitted for security reasons".
- WordPress moves the temp file into
wp-content/uploads/YYYY/MM. This message is a failure here. - WordPress scales the image and builds thumbnails. Failing here gives you "Post-processing of the image failed".
Reaching stage 3 tells you a lot for free: PHP's temp directory works, the file type is accepted, and the destination folder exists. The remaining possibilities are narrow.
Four causes, in checking order
1. The account is out of space
On shared and managed hosting, your account has a quota that has nothing to do with the physical partition. When it's reached, writes fail and the filesystem still looks healthy from the command line.
Check the disk usage figure in your hosting control panel, not df. If it's at the limit, the usual culprits are backup archives inside wp-content, an unrotated debug.log, and years of accumulated -scaled and thumbnail variants. Clearing those buys back a surprising amount.
On a server you control, also run df -i. Inode exhaustion produces identical symptoms while space remains free, and a site with a large file-based cache gets there faster than you'd expect.
2. Wrong ownership on an existing folder
The classic post-migration fault. Restoring a backup over SSH leaves wp-content/uploads owned by root or by your login user, and PHP — running as www-data, apache, or a per-account user — can no longer write into it.
ls -la wp-content/uploads/2026/
Compare the owner against the PHP user. When they differ:
chown -R www-data:www-data wp-content/uploads
Then confirm the mode: 755 on directories, 644 on files. Not 777 — that hands write access to every account on the machine, and it papers over the ownership fault rather than fixing it.
Without root, this is a one-line ticket: "uploads is owned by the wrong user after a restore, please reset ownership to the PHP user."
3. open_basedir confinement
Hardened hosting restricts PHP to a list of allowed paths. If either the temp directory or the uploads directory sits outside that list, the move is refused — permissions immaculate, ownership correct, file still doesn't land.
The tell is in the PHP error log rather than on screen: an open_basedir restriction in effect warning naming both paths. Common on sites served from a symlinked release directory, where the real path differs from the one PHP was told about.
4. The filesystem won't take writes
Containerised deployments mount the application read-only, with uploads expected to be a separate writable volume. If that volume is missing or mounted at the wrong path, every upload fails at this exact step and no permission change makes a difference. SELinux in enforcing mode does the same thing on traditional servers — ls -Z shows the context, and the httpd write boolean is usually what's missing.
Why some files move and others don't
This is the most useful clue in the whole exercise, so it's worth being deliberate about it. Upload a 20 KB PNG and a 4 MB one:
| Result | What it means |
|---|---|
| Both fail | Ownership, open_basedir, or a read-only filesystem. Size is irrelevant to all three. |
| Small succeeds, large fails | A space or quota ceiling. There's room for a little and not for a lot. |
| Both succeed now | A quota that was cleared, or a transient full disk. Worth finding out which before it recurs. |
Cleaning up afterwards
Failed uploads sometimes leave an attachment record behind with no file under it — the Media Library shows a broken thumbnail that resists deletion. Delete those entries once the underlying fault is fixed, then re-upload cleanly. Leaving them creates 404s on any page that referenced them and confuses later debugging.
If the failure caught a batch mid-flight, check which items actually landed before re-running the whole set. Half-uploaded batches are how duplicate media libraries start.
FAQ
What does this message mean?
PHP received your file into its temp directory and the copy into wp-content/uploads failed. The destination folder exists; writing into it is what was blocked.
How is it different from "unable to create directory"?
That message means the month folder couldn't be made at all. This one means it's there and the file couldn't be written into it.
Can a hosting quota cause it?
Yes, and it's easy to miss — an account quota can be full while the partition shows free space. Check the control panel figure, not df.
What permissions should uploads have?
755 on directories, 644 on files, owned by the PHP user. Ownership is what usually breaks; the permission bits often look fine.
Why do small files work and large ones fail?
That pattern points at space or quota. Permission faults don't care how big the file is.
Can open_basedir cause it?
Yes — if either the temp or the uploads directory falls outside PHP's allowed paths, the move is refused despite correct permissions.
Re-running the batch, without the batch
When a server fault eats half an upload, the tedious part is doing the whole set again by hand. Fig2WP sends a Figma selection straight to the Media Library in one press — titles and alt text included — so a re-run costs a click rather than an afternoon.