diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 5487c8fbe4..10ab57e41d 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -343,6 +343,7 @@ add_action( 'post_updated', 'wp_save_post_revision', 10, 1 );
 add_action( 'publish_post', '_publish_post_hook', 5, 1 );
 add_action( 'transition_post_status', '_transition_post_status', 5, 3 );
 add_action( 'transition_post_status', '_update_term_count_on_transition_post_status', 10, 3 );
+add_action( 'wp_no_object_term_count_found', 'wp_update_term_count_for_post_type', 10, 2 );
 add_action( 'comment_form', 'wp_comment_form_unfiltered_html_nonce' );
 add_action( 'admin_init', 'send_frame_options_header', 10, 0 );
 add_action( 'welcome_panel', 'wp_welcome_panel' );
diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php
index 2df5ac6b41..c6b00a21eb 100644
--- a/src/wp-includes/taxonomy.php
+++ b/src/wp-includes/taxonomy.php
@@ -3124,6 +3124,141 @@ function wp_update_term_count_now( $terms, $taxonomy ) {
 	return true;
 }
 
+/**
+ * Retrieves the term count for a specific object type.
+ *
+ * @since 5.0.0
+ *
+ * @param int    $term_id     Term ID.
+ * @param string $taxonomy    Taxonomy name.
+ * @param string $object_type Object type.
+ *
+ * @return WP_Error|int WP_Error on failure, object term count otherwise.
+ */
+function wp_get_term_count_for_object_type( $term_id, $taxonomy, $object_type ) {
+	if ( ! taxonomy_exists( $taxonomy ) ) {
+		return new WP_Error(
+			'invalid_taxonomy',
+			__( 'Invalid taxonomy.' ),
+			array(
+				'taxonomy' => $taxonomy,
+			)
+		);
+	}
+
+	if ( ! is_object_in_taxonomy( $object_type, $taxonomy ) ) {
+		return new WP_Error(
+			'invalid_object_type',
+			__( 'Object type is not in taxonomy.' ),
+			array(
+				'object_type' => $object_type,
+				'taxonomy'    => $taxonomy,
+			)
+		);
+	}
+
+	$term = get_term( $term_id, $taxonomy );
+
+	if ( is_wp_error( $term ) ) {
+		return $term;
+	}
+
+	$taxonomy_object = get_taxonomy( $taxonomy );
+
+	// are these right?
+	if ( 0 === $term->count ) {
+		$count = 0;
+	} elseif ( 1 >= count( $taxonomy_object->object_type ) ) {
+		$count = $term->count;
+	} else {
+		$count = wp_get_term_count_for_object_type_from_meta( $term_id, $object_type );
+
+		// No calculated count found for this object type.
+		if ( false === $count ) {
+			/**
+			 * Fires when no term count is found for a given object type.
+			 *
+			 * This action provides an opportunity for third parties to run count routines.
+			 * When the object type is a post type, the count routine is run automatically.
+			 * See wp_update_term_count_for_post_type().
+			 *
+			 * @since 5.0.0
+			 *
+			 * @param WP_Term $term        Term object.
+			 * @param string  $object_type Object type name.
+			 */
+			do_action( 'wp_no_object_term_count_found', $term, $object_type );
+
+			// Try one more fetch after a count has potentially been triggered.
+			$count = (int) wp_get_term_count_for_object_type_from_meta( $term_id, $object_type );
+		}
+	}
+
+	/**
+	 * Filters the object count for a given term.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param int     $count       Count of objects of the given `$object_type` belonging to the term.
+	 * @param WP_Term $term        Term object.
+	 * @param string  $object_type Object type.
+	 */
+	return apply_filters( 'wp_term_count_for_object_type', $count, $term, $object_type );
+}
+
+/**
+ * Triggers a term count refresh when `$object_type` is a post type.
+ *
+ * Term-object counts are generally recalculated at the time that term-relationships are
+ * updated. See eg wp_set_object_terms(). But terms that were last counted before the
+ * introduction of term-object counts, this data will initially be missing. This function
+ * forces a calculation of those counts when the object is a post type. Object types
+ * that are not post types must have their own separate mechanism for triggering
+ * these counts.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Term $term        Term object.
+ * @param string  $object_type Object type.
+ */
+function wp_update_term_count_for_post_type( $term, $object_type ) {
+	// Non-post-types cannot be handled automatically.
+	if ( ! post_type_exists( $object_type ) ) {
+		return;
+	}
+
+	// Taxonomies with their own count callbacks cannot be handled automatically.
+	$taxonomy = get_taxonomy( $term->taxonomy );
+	if ( ! empty( $taxonomy->update_count_callback ) ) {
+		return;
+	}
+
+	wp_update_term_count_now( array( $term->term_taxonomy_id ), $term->taxonomy );
+}
+
+/**
+ * Get the cached term-object count from termmeta.
+ *
+ * @param int    $term_id     ID of the term.
+ * @param string $object_type Object type.
+ * @return bool|int Returns false when no metadata is found, which indicates that a count has not taken place.
+ */
+function wp_get_term_count_for_object_type_from_meta( $term_id, $object_type ) {
+	$term_object_count = get_term_meta( $term_id, '_wp_object_count_' . $object_type, true );
+	if ( $term_object_count ) {
+		return (int) $term_object_count;
+	}
+
+	$counted_object_types = (array) get_term_meta( $term_id, '_wp_counted_object_types', true );
+
+	// When the object type is marked counted and no meta key exists, the count is 0.
+	if ( in_array( $object_type, $counted_object_types, true ) ) {
+		return 0;
+	}
+
+	return false;
+}
+
 //
 // Cache
 //
@@ -3638,6 +3773,7 @@ function _prime_term_caches( $term_ids, $update_meta_cache = true ) {
  *
  * @access private
  * @since 2.3.0
+ * @since 5.0.0 Store term counts on a per object type basis in meta.
  *
  * @global wpdb $wpdb WordPress database abstraction object.
  *
@@ -3661,27 +3797,64 @@ function _update_post_term_count( $terms, $taxonomy ) {
 	}
 
 	if ( $object_types ) {
-		$object_types = esc_sql( array_filter( $object_types, 'post_type_exists' ) );
+		$object_types = array_filter( $object_types, 'post_type_exists' );
 	}
 
-	foreach ( (array) $terms as $term ) {
+	foreach ( (array) $terms as $tt_id ) {
 		$count = 0;
 
+		$term = get_term_by( 'term_taxonomy_id', $tt_id );
+
+		// Remove previous counts to prevent stale data if an object type is removed from a taxonomy.
+		$counted_object_types = (array) get_term_meta( $term->term_id, '_wp_counted_object_types', true );
+
+		foreach ( $counted_object_types as $o_type ) {
+			delete_term_meta( $term->term_id, '_wp_object_count_' . $o_type );
+		}
+
+		delete_term_meta( $term->term_id, '_wp_counted_object_types' );
+
+		$term_count_meta = array();
+
+		if ( $object_types ) {
+			foreach ( $object_types as $type ) {
+				$current_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->term_relationships, $wpdb->posts WHERE $wpdb->posts.ID = $wpdb->term_relationships.object_id AND post_status = 'publish' AND post_type = %s AND term_taxonomy_id = %d", $type, $tt_id ) );
+
+				$count += $current_count;
+
+				$term_count_meta[ $type ] = $current_count;
+			}
+		}
+
 		// Attachments can be 'inherit' status, we need to base count off the parent's status if so.
 		if ( $check_attachments ) {
-			$count += (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->term_relationships, $wpdb->posts p1 WHERE p1.ID = $wpdb->term_relationships.object_id AND ( post_status = 'publish' OR ( post_status = 'inherit' AND post_parent > 0 AND ( SELECT post_status FROM $wpdb->posts WHERE ID = p1.post_parent ) = 'publish' ) ) AND post_type = 'attachment' AND term_taxonomy_id = %d", $term ) );
+			$attachment_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->term_relationships, $wpdb->posts p1 WHERE p1.ID = $wpdb->term_relationships.object_id AND ( post_status = 'publish' OR ( post_status = 'inherit' AND post_parent > 0 AND ( SELECT post_status FROM $wpdb->posts WHERE ID = p1.post_parent ) = 'publish' ) ) AND post_type = 'attachment' AND term_taxonomy_id = %d", $tt_id ) );
+
+			$count += $attachment_count;
+
+			$term_count_meta['attachment'] = $attachment_count;
+
+			// Re-add attachment so the meta gets saved below.
+			$object_types[] = 'attachment';
 		}
 
-		if ( $object_types ) {
-			$count += (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->term_relationships, $wpdb->posts WHERE $wpdb->posts.ID = $wpdb->term_relationships.object_id AND post_status = 'publish' AND post_type IN ('" . implode( "', '", $object_types ) . "') AND term_taxonomy_id = %d", $term ) );
+		// Save individual counts for each object type in term meta.
+		if ( 1 < count( $term_count_meta ) ) {
+			foreach ( $object_types as $type ) {
+				if ( ! empty( $term_count_meta[ $type ] ) ) {
+					update_term_meta( $term->term_id, '_wp_object_count_' . $type, (int) $term_count_meta[ $type ] );
+				}
+			}
+
+			update_term_meta( $term->term_id, '_wp_counted_object_types', $object_types );
 		}
 
 		/** This action is documented in wp-includes/taxonomy.php */
-		do_action( 'edit_term_taxonomy', $term, $taxonomy->name );
-		$wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $term ) );
+		do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name );
+		$wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $tt_id ) );
 
 		/** This action is documented in wp-includes/taxonomy.php */
-		do_action( 'edited_term_taxonomy', $term, $taxonomy->name );
+		do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name );
 	}
 }
 
diff --git a/tests/phpunit/tests/term/wpGetObjectTerms.php b/tests/phpunit/tests/term/wpGetObjectTerms.php
index e7851dcaee..4bf496ffe0 100644
--- a/tests/phpunit/tests/term/wpGetObjectTerms.php
+++ b/tests/phpunit/tests/term/wpGetObjectTerms.php
@@ -599,6 +599,14 @@ class Tests_Term_WpGetObjectTerms extends WP_UnitTestCase {
 		$p = self::factory()->post->create();
 		wp_set_object_terms( $p, $terms, 'wptests_tax' );
 
+		/**
+		 * `wp_set_object_terms()` populates the cache, but we need it empty to verify
+		 * behavior of 'update_term_meta_cache'.
+		 */
+		foreach ( $terms as $t ) {
+			wp_cache_delete( $t, 'term_meta' );
+		}
+
 		$found = wp_get_object_terms(
 			$p, 'wptests_tax', array(
 				'update_term_meta_cache' => false,
diff --git a/tests/phpunit/tests/term/wpGetTermCountForObjectType.php b/tests/phpunit/tests/term/wpGetTermCountForObjectType.php
new file mode 100644
index 0000000000..1e31110868
--- /dev/null
+++ b/tests/phpunit/tests/term/wpGetTermCountForObjectType.php
@@ -0,0 +1,242 @@
+<?php
+
+/**
+ * Class Tests_Term_wpGetTermCountForObjectType
+ *
+ * @group taxonomy
+ * @ticket 38280
+ */
+class Tests_Term_wpGetTermCountForObjectType extends WP_UnitTestCase {
+
+	/**
+	 * Test when a taxonomy is invalid.
+	 */
+	public function test_wp_get_term_count_for_object_type_invalid_taxonomy() {
+		$actual = wp_get_term_count_for_object_type( 0, 'does-not-exist', 'post' );
+
+		$this->assertWPError( $actual );
+		$this->assertSame( 'invalid_taxonomy', $actual->get_error_code() );
+	}
+
+	/**
+	 * Test when an object type is not in a taxonomy.
+	 */
+	public function test_wp_get_term_count_for_object_type_object_not_in_taxonomy() {
+		$this->assertWPError( wp_get_term_count_for_object_type( 0, 'category', 'page' ) );
+	}
+
+	/**
+	 * Test when an invalid term is passed.
+	 */
+	public function test_wp_get_term_count_for_object_type_invalid_term() {
+		$actual = wp_get_term_count_for_object_type( 0, 'category', 'post' );
+
+		$this->assertWPError( $actual );
+		$this->assertSame( 'invalid_term', $actual->get_error_code() );
+	}
+
+	/**
+	 * Test when a taxonomy belongs to a single object type. No term meta should be
+	 * stored and the count property should be used.
+	 */
+	public function test_wp_get_term_count_for_object_type_single_object_type() {
+		$term_id = self::factory()->term->create( array( 'taxonomy' => 'category' ) );
+		$post_id = self::factory()->post->create(
+			array(
+				'post_type'     => 'post',
+				'post_category' => array(
+					$term_id,
+				),
+			)
+		);
+
+		$this->assertFalse( (bool) get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'category', 'post' ) );
+
+		$term_object = get_term( $term_id, 'category' );
+		$this->assertEquals( 1, $term_object->count );
+
+		wp_remove_object_terms( $post_id, array( $term_id ), 'category' );
+
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'category', 'post' ) );
+
+		$term_object = get_term( $term_id, 'category' );
+		$this->assertEquals( 0, $term_object->count );
+	}
+
+	/**
+	 * Test when a taxonomy belongs to more than one object, and at least one object is not a post type.
+	 */
+	public function test_wp_get_term_count_for_object_type_non_post_type() {
+		register_taxonomy(
+			'wptests_tax',
+			array(
+				'user',
+				'foo'
+			)
+		);
+
+		$term_id = self::factory()->term->create(
+			array(
+				'taxonomy' => 'wptests_tax',
+			)
+		);
+
+		$foo_id  = 99999;
+
+		wp_set_object_terms( $foo_id, $term_id, 'wptests_tax' );
+
+		// 'foo' object doesn't have an update count callback, so the value is always 0.
+		$count = wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'foo' );
+		$this->assertSame( 0, $count );
+	}
+
+	/**
+	 * Test when a taxonomy belongs to multiple object types.
+	 */
+	public function test_wp_get_term_count_for_object_type_multiple_object_types() {
+		register_post_type( 'wptests_cpt' );
+		register_taxonomy(
+			'wptests_tax',
+			array(
+				'post',
+				'wptests_cpt',
+			)
+		);
+
+		$term_id        = self::factory()->term->create( array( 'taxonomy' => 'wptests_tax' ) );
+		$post_id        = self::factory()->post->create( array( 'post_type' => 'post' ) );
+		$custom_post_id = self::factory()->post->create( array( 'post_type' => 'wptests_cpt' ) );
+
+		// When a term has no relationships, no meta should be stored.
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'wptests_cpt' ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_wptests_cpt', true ) );
+
+		wp_set_object_terms( $post_id, array( $term_id ), 'wptests_tax' );
+
+		// Term has relationships, meta should be stored caching types counted and counts for each type > 0.
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'wptests_cpt' ) );
+		$this->assertEquals( array( 'post', 'wptests_cpt' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_wptests_cpt', true ) );
+
+		wp_set_object_terms( $custom_post_id, array( $term_id ), 'wptests_tax' );
+
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'wptests_cpt' ) );
+		$this->assertEquals( array( 'post', 'wptests_cpt' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_wptests_cpt', true ) );
+
+		// Total count should be stored in the term's count property.
+		$term_object = get_term( $term_id, 'wptests_tax' );
+		$this->assertEquals( 2, $term_object->count );
+
+		wp_remove_object_terms( $custom_post_id, array( $term_id ), 'wptests_tax' );
+
+		// Object count cache should be removed.
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'wptests_cpt' ) );
+		$this->assertEquals( array( 'post', 'wptests_cpt' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_wptests_cpt', true ) );
+
+		wp_remove_object_terms( $post_id, array( $term_id ), 'wptests_tax' );
+
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'wptests_cpt' ) );
+		$this->assertEquals( array( 'post', 'wptests_cpt' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_wptests_cpt', true ) );
+	}
+
+	/**
+	 * Term count must be generated on the fly (as for "legacy" terms).
+	 */
+	public function test_count_should_be_generated_for_legacy_terms() {
+		register_post_type( 'wptests_cpt' );
+		register_taxonomy(
+			'wptests_tax',
+			array(
+				'post',
+				'wptests_cpt',
+			)
+		);
+
+		$t = self::factory()->term->create( array( 'taxonomy' => 'wptests_tax' ) );
+		$p = self::factory()->post->create( array( 'post_type' => 'post' ) );
+
+		wp_set_object_terms( $p, array( $t ), 'wptests_tax' );
+
+		// Mimic "legacy" terms, which will not have the proper counts.
+		delete_term_meta( $t, '_wp_object_count_post' );
+		delete_term_meta( $t, '_wp_counted_object_types' );
+
+		$found = wp_get_term_count_for_object_type( $t, 'wptests_tax', 'post' );
+		$this->assertSame( 1, $found );
+	}
+
+	/**
+	 * Test when a taxonomy belongs to multiple object types, one of which is attachments.
+	 */
+	public function test_wp_get_term_count_for_object_type_multiple_object_types_attachment() {
+		register_taxonomy(
+			'wptests_tax',
+			array(
+				'post',
+				'attachment',
+			)
+		);
+
+		$term_id       = self::factory()->term->create( array( 'taxonomy' => 'wptests_tax' ) );
+		$post_id       = self::factory()->post->create( array( 'post_type' => 'post' ) );
+		$attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg', $post_id );
+
+		// When a term has no relationships, no meta should be stored.
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'attachment' ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_attachment', true ) );
+
+		wp_set_object_terms( $post_id, array( $term_id ), 'wptests_tax' );
+
+		// Term has relationships, meta should be stored caching types counted and counts for each type > 0.
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'attachment' ) );
+		$this->assertEquals( array( 'post', 'attachment' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_attachment', true ) );
+
+		wp_set_object_terms( $attachment_id, array( $term_id ), 'wptests_tax' );
+
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'attachment' ) );
+		$this->assertEquals( array( 'post', 'attachment' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_attachment', true ) );
+
+		// Total count should be stored in the term's count property.
+		$term_object = get_term( $term_id, 'wptests_tax' );
+		$this->assertEquals( 2, $term_object->count );
+
+		wp_remove_object_terms( $post_id, array( $term_id ), 'wptests_tax' );
+
+		// Object count cache should be removed.
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 1, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'attachment' ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEquals( 1, get_term_meta( $term_id, '_wp_object_count_attachment', true ) );
+		wp_remove_object_terms( $attachment_id, array( $term_id ), 'wptests_tax' );
+
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'post' ) );
+		$this->assertEquals( 0, wp_get_term_count_for_object_type( $term_id, 'wptests_tax', 'attachment' ) );
+		$this->assertEquals( array( 'post', 'attachment' ), get_term_meta( $term_id, '_wp_counted_object_types', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_post', true ) );
+		$this->assertEmpty( get_term_meta( $term_id, '_wp_object_count_attachment', true ) );
+	}
+}
