The Media Library

Every file you upload becomes a post of type attachment, with its bytes in wp-content/uploads and its paths in post meta.

Syntax$uploads = wp_upload_dir(); // array: path, url, subdir, basedir, baseurl, error

WordPress has no separate media table. When you upload a file, WordPress writes the bytes into wp-content/uploads and inserts a row into wp_posts with post_type of attachment. Everything you know about posts applies: it has an ID, an author, a title, a date, a parent, and post meta. That is why the Media Library reuses the post list table, and why attachments show up in WP_Query.

The fields that matter

post_mime_type holds image/jpeg or application/pdf. post_parent is the post the file was uploaded to, or 0 for a library upload. post_status is inherit, never publish — a query filtering on publish will return nothing. post_content is the description, post_excerpt is the caption, and alt text lives in the meta key _wp_attachment_image_alt.

Two meta keys carry the file itself. _wp_attached_file stores the path relative to the uploads base directory, such as 2026/09/photo.jpg. _wp_attachment_metadata stores an array of width, height, the file name, and a sizes sub-array naming every generated thumbnail.

Finding the folder

wp_upload_dir() returns the layout: basedir and baseurl point at the uploads root, subdir is the /YYYY/MM segment when the uploads_use_yearmonth_folders option is on, path and url are the two joined, and error is false or a message string. Never hardcode wp-content/uploads; multisite and UPLOADS move it.

Example

Example · php
<?php
/**
 * Collect everything WordPress knows about one attachment.
 *
 * @param int $attachment_id Attachment post ID.
 * @return array Empty array if the ID is not an attachment.
 */
function scx_describe_attachment( int $attachment_id ): array {
	$post = get_post( $attachment_id );

	// An attachment is just a post, so guard on the type.
	if ( ! $post || 'attachment' !== $post->post_type ) {
		return array();
	}

	$uploads = wp_upload_dir(); // basedir, baseurl, subdir, path, url, error

	// Path relative to the uploads root, e.g. "2026/09/photo.jpg".
	$relative = get_post_meta( $attachment_id, '_wp_attached_file', true );

	return array(
		'title'  => $post->post_title,
		'mime'   => $post->post_mime_type,   // "image/jpeg"
		'parent' => $post->post_parent,      // post it was uploaded to, or 0
		'alt'    => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
		'file'   => $uploads['basedir'] . '/' . $relative, // absolute path on disk
		'url'    => wp_get_attachment_url( $attachment_id ), // not $post->guid
		// width, height, file and the sizes sub-array
		'meta'   => wp_get_attachment_metadata( $attachment_id ),
	);
}

When to use it

  • List every PDF a client has uploaded, newest first, on a downloads page.
  • Find the absolute path of an uploaded file so a CLI script can process it.
  • Store an importer's downloaded images in their own uploads subfolder.
  • Bulk-fill missing alt text by writing the _wp_attachment_image_alt meta key.

More examples

Querying the library like any other post type

The two arguments people forget are post_status => 'inherit' and post_mime_type. Reach for this when you need a document list, a gallery page, or an audit of what a client has uploaded. post_mime_type also accepts a partial type such as 'image' or an array of types.

Example · php
$pdfs = new WP_Query(
	array(
		'post_type'      => 'attachment',
		'post_status'    => 'inherit', // attachments are never 'publish'
		'post_mime_type' => 'application/pdf',
		'posts_per_page' => 20,
		'orderby'        => 'date',
		'order'          => 'DESC',
	)
);

foreach ( $pdfs->posts as $pdf ) {
	printf(
		'<li><a href="%s">%s</a></li>',
		esc_url( wp_get_attachment_url( $pdf->ID ) ),
		esc_html( get_the_title( $pdf ) )
	);
}

wp_reset_postdata();

Routing one upload into its own folder

The upload_dir filter changes where the next write lands. Add it, do the upload, remove it — leaving it on permanently redirects every upload on the site, including ones made in the admin. Note that all four keys must agree or the stored URL will not match the file on disk.

Example · php
/**
 * Send files uploaded through our importer to /uploads/imports
 * instead of the usual year/month folder.
 */
function scx_import_upload_dir( array $dirs ): array {
	$dirs['subdir'] = '/imports';
	$dirs['path']   = $dirs['basedir'] . $dirs['subdir'];
	$dirs['url']    = $dirs['baseurl'] . $dirs['subdir'];

	return $dirs;
}

// Add the filter only for the duration of this one upload.
add_filter( 'upload_dir', 'scx_import_upload_dir' );
$attachment_id = media_handle_sideload( $file_array, 0 );
remove_filter( 'upload_dir', 'scx_import_upload_dir' );

Reading the generated sizes out of attachment metadata

Useful when auditing which sizes actually exist on disk after adding a new one, or when building a custom picker. The trap shown in the comment catches people out constantly: entries in sizes hold a bare filename, so you must join them to the parent image's directory yourself, or call wp_get_attachment_image_src() and let WordPress do it.

Example · php
$meta = wp_get_attachment_metadata( $attachment_id );

/*
$meta looks like:
array(
  'width'  => 2560,
  'height' => 1440,
  'file'   => '2026/09/photo.jpg',
  'sizes'  => array(
      'thumbnail' => array( 'file' => 'photo-150x150.jpg', 'width' => 150, ... ),
      'medium'    => array( 'file' => 'photo-300x169.jpg', 'width' => 300, ... ),
  ),
)
*/

// Sizes only store a file NAME, not a path. Rebuild the URL from the parent.
$base = dirname( wp_get_attachment_url( $attachment_id ) );

foreach ( $meta['sizes'] ?? array() as $name => $size ) {
	echo esc_html( $name ) . ': ' . esc_url( $base . '/' . $size['file'] ) . "\n";
}

Discussion

  • Be the first to comment on this lesson.