Skip to content
ThemesIonic — home
WordPress Tutorials

WordPress Shortcodes Explained

A shortcode is a string in your content that WordPress swaps for the return value of a PHP function. Understanding that one sentence explains every shortcode bug you will ever hit.

Updated 1 min read intermediate

What a shortcode is, exactly: a tag in square brackets that WordPress replaces with whatever a registered PHP function returns. [gallery] is not markup and not a template tag. It is a lookup in an array of tag names, and every shortcode problem traces back to that lookup failing or running in the wrong place.

The mechanism

WordPress keeps a global array mapping tag names to callbacks. Registering a shortcode adds an entry. Rendering content runs a filter on the_content that scans for registered tags and swaps each one for its callback's return value.

Two facts follow immediately, and they explain most of the trouble:

  • A tag nobody registered is left alone. It prints verbatim, brackets included. That is not an error, it is the documented behaviour.
  • The swap happens in the_content. Content displayed by something that skips that filter never gets the substitution.

Registering one

The smallest useful example, in a site-specific plugin rather than a theme:

<?php
/*
Plugin Name: Site Shortcodes
Description: Shortcodes this site owns.
Version: 1.0.0
*/

add_shortcode('current_year', function (): string {
    return esc_html(wp_date('Y'));
});

Written in a post as [current_year], that outputs the year. Two rules are already visible:

Return, never echo. The callback's return value is what gets substituted. A callback that echoes prints its output at the top of the page, before the content, because output buffering has already flushed by then. This is the single most common shortcode bug.

Escape on output. Shortcode output goes straight into the page. esc_html for text, esc_url for links, esc_attr for attribute values.

Put this in a plugin, not functions.php. A shortcode registered by a theme stops resolving the moment the theme changes, and every page using it starts showing raw brackets — the same reasoning as in creating a WordPress child theme.

Attributes

Attributes arrive as an array, and shortcode_atts merges them over your defaults:

add_shortcode('recent_posts', function (array|string $atts = []): string {
    $atts = shortcode_atts([
        'count'    => 5,
        'category' => '',
    ], $atts, 'recent_posts');

    $posts = get_posts([
        'numberposts'   => max(1, (int) $atts['count']),
        'category_name' => sanitize_title($atts['category']),
    ]);

    if ($posts === []) {
        return '';
    }

    $items = array_map(
        static fn (WP_Post $post): string => sprintf(
            '<li><a href="%s">%s</a></li>',
            esc_url(get_permalink($post)),
            esc_html(get_the_title($post))
        ),
        $posts
    );

    return '<ul class="recent-posts">'.implode('', $items).'</ul>';
});

Used as [recent_posts count="3" category="tutorials"]. Note the details that bite:

  • Attribute names are lower-cased by WordPress before your callback sees them. [recent_posts Count="3"] arrives as count.
  • Values are always strings. Cast them. A count of "3" used in arithmetic behaves, but "three" silently becomes zero.
  • No attributes means an empty string, not an array, in older code paths. Typing the parameter as array|string and defaulting it avoids a fatal error.
  • Sanitise every value. Attributes are author input, and on a multi-author site authors are not all trusted equally, as WordPress user roles explained covers.

Enclosing shortcodes

A callback's second parameter receives whatever sits between an opening and closing tag:

add_shortcode('callout', function (array|string $atts, ?string $content = null): string {
    $atts = shortcode_atts(['type' => 'note'], $atts, 'callout');

    return sprintf(
        '<aside class="callout callout--%s">%s</aside>',
        esc_attr($atts['type']),
        do_shortcode(wp_kses_post($content ?? ''))
    );
});

Written as [callout type="warning"]Back up first.[/callout]. The do_shortcode call on the inner content is what allows nesting; without it, a shortcode inside a shortcode prints as text. wp_kses_post keeps the inner HTML to what a post may legitimately contain.

Where shortcodes do not run

This is the source of the "it prints as text" reports:

Location Does it resolve?
Post or page content Yes
Excerpts No, they are stripped
Widget text areas Yes in the block widget editor, historically no in classic text widgets
Theme templates Only inside do_shortcode()
Post titles No
Term descriptions No, unless the theme filters them
Email bodies No, unless the mailer runs the filter
Block editor code or preformatted blocks No, by design

If output must appear in a template, call the function directly rather than paying for a string scan:

// Prefer this in your own templates.
echo site_recent_posts_html(3);

// Only when the function belongs to code you do not control.
echo do_shortcode('[third_party_widget id="7"]');

Shortcodes versus blocks

Blocks won for editor-placed content, and rightly: an editor sees the result instead of a bracketed guess. Shortcodes still have a place.

Use a block when someone arranges it visually on a page. Use a shortcode when the same output is reused across dozens of posts and you want one place to change it, or when the value has to survive being pasted into plain text. Existing shortcodes keep working in the block editor through the Shortcode block, and WooCommerce still leans on them heavily, as displaying WooCommerce products with shortcodes shows. Blocks themselves are covered in what is a block in WordPress.

Common mistakes

  • Echoing instead of returning. Output lands at the top of the page. Always return a string.
  • Registering in the theme. Switching themes turns every usage into visible brackets.
  • Using a generic tag name. [button] or [list] collides with plugins. Prefix yours.
  • Trusting attributes. Passing an unsanitised attribute into a query or into HTML is an injection route.
  • Leaving debris behind. Deleting a plugin leaves its shortcodes in your posts as raw text. Search the content for the tag before removing the plugin, per deleting a WordPress plugin completely.
  • Forgetting do_shortcode on inner content. Nesting silently fails.

Verify

Publish a draft using the shortcode with and without attributes, and view it on the front end rather than in the editor preview. Confirm the output appears where the tag was, not above the content. Then remove one attribute and check the default applies. If the tag prints as text, enable debugging per how to enable WordPress debug mode and check that the file registering it is actually loading.

Frequently asked

Nothing has registered that tag. Either the plugin or theme that provided it is inactive or deleted, the tag is misspelled, or the shortcode sits somewhere that does not run the_content, such as a raw template echo.
Blocks, for anything an editor places visually. Shortcodes remain the right tool for output reused across many pages, and for values that must work inside text, emails and widget areas.
Yes, by wrapping it in do_shortcode, but calling the underlying function directly is faster and clearer. Reach for do_shortcode only when the function belongs to code you do not control.

Related guides