Every image on your site lives somewhere inside one directory tree: wp-content/uploads. Open it and you find folders called 2023, 2024, 2025, each holding folders named 01 through 12, each of those holding far more files than you ever uploaded.
That layout is not a convention and not a plugin. It is a single boolean option in your database, read once per upload, and the full path is computed at runtime rather than stored anywhere. Knowing that changes what is possible: moving the folder is a one line change, and the hard part is somewhere else entirely.
This is the part most guides get wrong. They tell you to define a constant, drag the folder across, and run a find and replace on a database dump. Two of those three steps are fine. The third quietly empties every page builder layout on the site.
The default layout and the option behind it
The function that decides where a file goes is _wp_upload_dir() in wp-includes/functions.php. It runs in three steps. First it reads the upload_path option; if that string is empty or is literally wp-content/uploads, the base becomes WP_CONTENT_DIR . '/uploads'. Second it works out the matching URL: the upload_url_path option if that is set, otherwise WP_CONTENT_URL plus /uploads on a default install, or the site URL joined with upload_path when that option holds a custom relative path. Third, and only if the uploads_use_yearmonth_folders option is truthy, it slices a timestamp into a subdirectory:
$subdir = '';
if ( get_option( 'uploads_use_yearmonth_folders' ) ) {
// Generate the yearly and monthly directories.
if ( ! $time ) {
$time = current_time( 'mysql' );
}
$y = substr( $time, 0, 4 );
$m = substr( $time, 5, 2 );
$subdir = "/$y/$m";
}
That is the whole mechanism. Six characters of a MySQL datetime string become the folder name. The function returns an array with five useful keys: basedir and baseurl (the base, with no date in it), subdir (the /2026/08 part), and path and url (base plus subdir, already joined), alongside an error key. The public wrapper wp_upload_dir() caches that array per request, runs it through the upload_dir filter and, unless you called wp_get_upload_dir(), tries to create the directory with wp_mkdir_p().
The default for the option is set once, at install time, in populate_options() in wp-admin/includes/schema.php, as 'uploads_use_yearmonth_folders' => 1 alongside 'upload_path' => '' and 'upload_url_path' => ''. It is a row in the options table, not a constant, which is why a plugin or a WP-CLI command can flip it and why it survives core updates untouched. In the admin it is the checkbox at Settings, Media, labelled “Organize my uploads into month- and year-based folders”. The two path fields above it are only rendered when upload_url_path holds a value, or when upload_path holds something other than wp-content/uploads. On a default install they are not on the page at all.
One detail worth knowing: the timestamp is not always “now”. _wp_upload_dir() accepts a $time argument, and media_handle_upload() passes the parent post’s post_date through whenever that post is not a page. Add an image while editing a post dated 2019 and the file lands in 2019/, not in this month. The same backdating happens in media_handle_sideload(), which is why importers scatter files across years. The folder name reflects the content date, not the day you pressed the button.
Why the year and month split exists
The honest answer is that it is an old filesystem workaround that is still worth keeping, for a different reason than the original one.
On ext3 without the dir_index feature, directory lookups were a linear scan, and a few thousand entries in one directory made every file open measurably slower. That problem is gone. Modern ext4 uses hashed b-tree directory indexes and XFS uses b-trees, so looking up a known filename in a directory holding two hundred thousand entries is not meaningfully slower than in one holding ten.
What did not get faster is enumeration. Anything that lists the directory has to read every entry and usually stat() each one: ls -l, an SFTP client drawing a file pane, PHP’s glob() and scandir(), your backup plugin building a manifest, rsync computing a file list, a malware scanner. Those costs scale with entry count and they are dominated by per file syscalls, not by disk throughput.
Treat the thresholds as rules of thumb rather than measurements, because they depend entirely on your storage. Under roughly ten thousand files in a single directory, nothing complains. In the tens of thousands, listing tools get sluggish and FTP clients start timing out on the directory listing. Past a few hundred thousand, backup and scanning tools are the ones that break first, usually by exhausting memory while building a file list. One asymmetry matters here: an ext4 directory does not shrink when you delete files from it, so a directory that once held hundreds of thousands of entries keeps paying the enumeration cost until the directory itself is removed.
None of that would matter much if one upload produced one file. It does not.
What a single upload actually writes
Drop one JPEG into the media library and WordPress writes the original, then one file per registered image size that the original is large enough to produce, all into the same year and month folder with the dimensions appended to the filename. Themes and plugins register their own sizes on top of the core ones, which is why the count varies so much between installs. The mechanics of that fan out are covered in how one upload becomes many files, so the short version here is: expect several files per image, not one.
Two things add to that. Since WordPress 5.3, if either dimension of the upload exceeds big_image_size_threshold, which core filters with a default of 2560 pixels in wp-admin/includes/image.php, WordPress creates a downscaled copy named filename-scaled.jpg, points the attachment at that copy, and keeps your untouched original on disk, recorded under original_image in the attachment metadata. Both files stay. Second, the image_editor_output_format filter decides what format sub-sizes are saved in. Since WordPress 6.7 core ships a default mapping that converts HEIC and HEIF input to JPEG, and a plugin can extend that map to WebP or AVIF. That filter converts rather than duplicates; plugins that keep a JPEG and a WebP side by side are writing the second copy themselves, which is where the file count really grows.
This blog is a small site and the ratio still holds. Its database has 784 rows of _wp_attachment_metadata and, counted across all of them, 4,795 file entries. Subtract the one top level entry per attachment and 4,011 of those are generated sub-sizes, an average of about five extra files for every upload. Only 8 uploads were large enough to cross the 2560 threshold and get a -scaled twin. All of it sits in exactly two date directories, 2026/07 with 617 originals and 2026/08 with 166.
Scale that up. A photography site with 20,000 uploads at the same ratio of about six files each holds something like 120,000 files. Split across twelve years of month folders that is roughly 800 per directory, which no tool will complain about. Flat, it is exactly the case where the backup plugin starts timing out.
Turning the split off, and what that does not do
Unchecking the box writes 0 to uploads_use_yearmonth_folders. From the next upload onward, $subdir is an empty string and files land directly in the root of uploads. Nothing else happens. Existing files do not move, and their stored paths still say 2025/03/....
They keep working because of how the URL is built. Each attachment stores a single meta value, _wp_attached_file, holding a path relative to basedir, for example 2026/07/header.jpg. wp_get_attachment_url() reads that value, asks wp_get_upload_dir() for the current base URL, and joins the two. The date is baked into the stored string, not into the setting, so old files resolve under the old subfolder while new ones resolve at the root. You end up with a mixed tree, which is untidy but harmless.
The real cost of a flat uploads folder is filename collisions. With year and month folders, logo.png can exist once per month without conflict. Flat, the whole history shares one namespace, so wp_unique_filename() starts appending -1, -2, -3 and you accumulate near duplicates that are hard to tell apart later. The same function also forces a number onto any upload whose name already ends in something like -1024x768, -scaled or -rotated, so that a real upload can never collide with a generated sub-size. If you are turning the option off to make files easier to find by hand, you are trading a small navigation problem for a larger naming one.
UPLOADS, upload_path and the upload_dir filter
There are three ways to change where uploads go, they apply in a fixed order, and none of them touches a file that already exists.
- The upload_path and upload_url_path options. Plain strings in the options table. If
upload_pathdoes not start withABSPATHit is treated as relative to it and joined withpath_join(), otherwise it is used as an absolute path. This is the oldest mechanism and it is the one a host’s migration script is most likely to have set behind your back. - The UPLOADS constant, defined in
wp-config.php. When it is set, core does$dir = ABSPATH . UPLOADS;and$url = trailingslashit( $siteurl ) . UPLOADS;, overriding the options above. Note both lines: the constant is glued ontoABSPATHand onto the site URL, so it can only describe a location inside the WordPress root, and it cannot describe a directory that is not web accessible. On multisite it is skipped while ms-files rewriting is enabled. - The upload_dir filter, which runs last, in
wp_upload_dir(), after the array has been fully assembled. It sees everything and can rewrite anything, including per upload routing by date, post type or user.
The filter has one trap that accounts for most of the broken snippets circulating. By the time it runs, path and url have already been built from the old base plus the subdirectory. Change only basedir and baseurl and the actual write still goes to the old location. You have to rebuild both derived keys:
<?php
// Load this from a small mu-plugin, not from a theme.
add_filter( 'upload_dir', function ( $dirs ) {
$dirs['basedir'] = WP_CONTENT_DIR . '/media';
$dirs['baseurl'] = content_url( '/media' );
// Required: path and url were already joined with subdir.
$dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
$dirs['url'] = $dirs['baseurl'] . $dirs['subdir'];
return $dirs;
} );
An mu-plugin is the right home for it. A theme switch should not be able to take your upload path with it, and an mu-plugin loads before regular plugins, so the base is already correct by the time anything else asks for it.
Whichever lever you pull, the result is the same: the next upload goes somewhere new, every existing file stays exactly where it is, and every existing attachment now resolves its URL against a base that no longer contains it. That is the migration problem, and it is a copying and search problem rather than a WordPress one.
Moving the files without breaking the site
Start from the mechanism. _wp_attached_file is relative. It contains 2026/07/header.jpg, not https://example.com/wp-content/uploads/2026/07/header.jpg. Change the base with any of the three levers and every one of those meta values is still correct without being touched. This is the single most useful fact about moving uploads, and almost no tutorial mentions it.
What does need fixing is every absolute URL that got copied into content: src and srcset attributes written into post_content when the block was saved, page builder layouts stored as JSON or serialized arrays in postmeta, theme options, widget rows, custom fields, and the guid column of attachment posts.
This is where the standard advice goes wrong. A dump plus sed, or a phpMyAdmin find and replace, will happily rewrite those strings and break every serialized array it passes through, because PHP serialization records the byte length of each string ahead of it. Turn s:52:"https://example.com/wp-content/uploads/a.jpg" into a shorter path and the recorded length no longer matches, PHP refuses to unserialize the row, and the widget or builder layout silently comes back empty. A proper search and replace unserializes each value, replaces inside it, and reserializes. WP-CLI does that by default.
# 1. Back up first. Both halves, not just the database.
wp db export backup-before-uploads-move.sql
tar -czf uploads-backup.tar.gz wp-content/uploads
# 2. Copy, do not move. Leave the old tree in place for now.
rsync -a wp-content/uploads/ wp-content/media/
# 3. Point WordPress at the new base (UPLOADS constant or upload_dir
# filter), then check what a replace would touch before touching it.
wp search-replace 'example.com/wp-content/uploads/'
'example.com/wp-content/media/'
--all-tables-with-prefix --skip-columns=guid --dry-run
# 4. Run it for real, then flush caches.
wp search-replace 'example.com/wp-content/uploads/'
'example.com/wp-content/media/'
--all-tables-with-prefix --skip-columns=guid
wp cache flush
Three notes on that. The search string omits the protocol so it catches http, https and protocol relative URLs in one pass. --skip-columns=guid is there because a post’s guid is an identifier that feed readers key on and should not change; attachment guids are a grey area, and leaving them alone is the safer default. And --dry-run reports replacement counts per table and column, which is the fastest way to discover that a page builder stores its layouts somewhere you did not expect.
Two things to do afterwards. Keep the old path alive for a while with a server level redirect from the old prefix to the new one, because external sites, cached HTML and search results still point at it. And delete the old tree only after you have loaded a handful of real pages and confirmed that both the main image and its responsive candidates resolve. Those candidates are assembled separately from the main src, which is covered in how core builds srcset, and they are the half people forget to check.
One thing you do not need to do is regenerate thumbnails. Regeneration rewrites files inside whatever the current upload directory is; it does not relocate anything and it will not repair a path mismatch. It is the right tool when sizes changed, not when locations did, and what regeneration actually fixes is worth reading before you spend an hour on it. Do check ownership on the new directory, though: wp_upload_dir() calls wp_mkdir_p() and, when that fails, puts a message into the error key of the array instead of a usable path, which is what surfaces in the media modal as a failed upload.
The order of those steps is the whole job, and it changes depending on where the files are going.
Pick your case below, a subfolder, a subdomain, a directory outside the web root, a shared location, and it writes the whole procedure with your paths filled in: the rsync line with the trailing slash that decides everything, the constant or the two options, whichever actually applies to your target, the search and replace with a dry run first, and the redirects so the old URLs do not die. The last step is deleting the old files, and it is last for a reason.
Uploads folder mover
Moving the uploads folder is eight steps in a fixed order, and the order is what keeps the media library intact. Say where the files are now and where they should go, and the plan is written for your case, with the constants, the options, the search and replace, and the redirects filled in. Everything is put together in this browser tab: no path, no URL and no file leaves it.
The commands assume WP-CLI on the server and a shell in the site root. Table prefixes are written as wp_, change them to yours. Nothing here is run for you: read every line before you paste it.
Offloading to object storage or a CDN
These two get talked about together and they are not the same operation at all.
A CDN in front of your origin changes nothing on disk and nothing in the database. The files stay in wp-content/uploads, _wp_attached_file is untouched, and either the CDN serves your hostname directly or a plugin swaps the hostname at output time. Remove it and everything still works, because the origin never stopped being complete.
Offloading moves the bytes out of your filesystem, and implementations split into two camps. Some keep _wp_attached_file as the same relative path and filter wp_get_attachment_url plus the upload_dir base URL so that everything resolves at the bucket. Others rewrite _wp_attached_file itself into a prefixed key or a full URL, which core partly tolerates: wp_get_attachment_url() first checks whether the stored value starts with basedir, then whether it contains wp-content/uploads as a pre 2.7 fallback, and only otherwise treats it as relative to the base URL.
Three consequences are worth knowing before you commit. Responsive images are built on a separate path: wp_calculate_image_srcset() assembles candidate URLs by joining the upload base URL, the directory part of the metadata file path and each size’s filename, so an offloader that only filters wp_get_attachment_url can leave you with a full size image on the bucket and every srcset candidate pointing at a file that is no longer on the origin. Second, anything that reads pixels from disk, including regeneration, EXIF extraction and batch processing, either has to pull the file back down or fails outright once the local copies are removed. Third, coming back is another full migration with the same serialization caveats, so treat the decision as reversible in principle but not casually.
Taking a read-only inventory
Before you move or delete anything, find out what is actually there. Ask the database first, because it already knows and the answer costs nothing:
# Originals per year and month, straight from the attachment meta.
# Replace wp_ with your own table prefix.
wp db query "SELECT SUBSTRING(meta_value,1,7) AS ym, COUNT(*) AS originals
FROM wp_postmeta
WHERE meta_key='_wp_attached_file'
GROUP BY ym ORDER BY ym;"
# How many attachments exist at all, by type.
wp db query "SELECT post_mime_type, COUNT(*) AS n
FROM wp_posts WHERE post_type='attachment'
GROUP BY post_mime_type ORDER BY n DESC;"
Then the disk, carefully. Walking a large uploads tree is itself an expensive operation: a find across two hundred thousand files issues two hundred thousand stat() calls, and on a cold cache with slow storage that is real IO contention on a live server. Run it once, at a quiet hour, with nice and ionice, and write the output to a file rather than repeating the walk for every question you have.
# Total size, then per top level folder. Read-only, but not free.
du -sh wp-content/uploads
du -sh wp-content/uploads/*/
# One walk, saved, then answer questions from the file.
# -printf is GNU find; on macOS use -exec stat -f '%z %N' {} +
nice -n 19 ionice -c3 find wp-content/uploads -type f
-printf '%st%pn' > /tmp/uploads-inventory.txt
wc -l < /tmp/uploads-inventory.txt # file count
sort -nr /tmp/uploads-inventory.txt | head -20 # the 20 biggest files
The interesting number is the gap between the two counts. Files on disk always outnumber attachment rows, partly because of sub-sizes and partly because plugins write into uploads without registering anything. On this site, exactly two _wp_attached_file values do not begin with a year: one under an elementor/ subfolder and one WooCommerce placeholder at the root. Cache folders, generated CSS, export files and abandoned plugin directories all accumulate in there and none of them appear in the media library. Separating those from genuinely unused attachments is its own job, and the safe order for doing it is set out in cleaning up the media library.
If you would rather not do any of that from a shell, this is exactly what the WunderPaint media library manager reports on: which attachments are used and where, which are duplicates, and how much disk each part of the library accounts for, without a full filesystem walk on every page load.
Symptom and cause
New uploads land at the root of uploads while older ones sit in year folders. Something set uploads_use_yearmonth_folders to 0, usually a migration tool or an optimisation plugin. Turning it back on affects new uploads only, and the mixed tree stays mixed until you migrate it deliberately.
Files land in a year that is not this one. Nothing is broken. The upload was attached to a post with an older post_date, and media_handle_upload() passes that date into the path calculation for every post type except pages.
You moved the folder, the media library still lists everything, and the front end shows broken images. The attachment rows and their relative paths are intact, so the list works; the base URL now points somewhere the files are not, or the hardcoded URLs in post content still name the old path. Check what a single attachment resolves to before assuming the database is damaged.
A page builder layout came back empty after a find and replace. The replacement changed the length of a string inside a serialized array without updating the recorded length, so PHP could not unserialize the row. Restore that table from the backup and redo the replacement with a tool that handles serialization.
Uploads fail immediately after relocating the folder. The new directory exists but is not writable by the PHP process user, so wp_mkdir_p() fails and wp_upload_dir() returns an error message in place of a usable path. Compare ownership and mode against the old uploads directory rather than reaching for 777.
The uploads folder is many times larger than the sum of the images you uploaded. Sub-sizes, -scaled copies whose full resolution originals are still on disk, converted formats, and plugin cache directories. The ratio of about six files per upload measured on this site is normal, not a fault.
The folder is downstream of the database
WordPress does not remember where your files are. It remembers a relative path per attachment and recomputes the absolute location on every request from an option, a constant and a filter. That indirection is why changing the upload base is genuinely a one line change, why nothing you do to the settings ever moves a byte, and why the difficult part of any migration is the absolute URLs that other subsystems copied into content years ago.
The year and month split is not there to help you browse. It exists so that the tools around WordPress, backups, sync, scanners, file managers, never have to enumerate a directory with six figures of entries. Since a single upload routinely produces five or more extra files, that ceiling arrives sooner than the upload count suggests. Unless you have a specific reason and a naming scheme to go with it, leave the option on.
If you are planning a move, do it in the order the mechanism implies: inventory from the database, copy rather than move, repoint the base, replace URLs with something that understands serialization, verify a few real pages including their responsive candidates, and only then delete the old tree. Every step in that list is reversible except the last one.