Review Snippets and AggregateRating: Whose Reviews Count? The Self-Serving Review Rule, Rating Math and Risks
Only reviews that are visible on the page, come from users and belong to the reviewed item are eligible for star ratings; a store writing its own rating into Organization runs into the self-serving rule. We filter Bookstore's book reviews through moderation, get the average from one query that EF Core translates to SQL, round it once, and keep page and markup identical with tests.
Version note: This post is based on Google Search Central's Review snippet, Product snippet and Merchant listing documentation as of 15 November 2026 and on schema.org 30.1; the code was built and run with .NET 10 (SDK 10.0.404), xUnit v3 4.0, AngleSharp 1.10 and Microsoft.EntityFrameworkCore.SqlServer 10.0.
Guess First
The home page of Bookstore, the online bookstore used as the shared example across this site's series, has a box saying "Our customers rate us 4.8"; the ratings come from the store's own post-order survey. Someone on the team adds this rating to the Organization node as aggregateRating. The Dune page shows an average of "4.3", but the markup says 4.2857142857142856, and the reviewCount field is fed from the page's view count. Question: which of these three pieces of markup is eligible for star ratings, and which carries a manual action risk?
In the previous post we moved the book page's price and stock into the markup correctly. In this post we add reader reviews: which types can carry a rating, whose reviews count, how the rating is calculated and rounded, and how the visible page and the markup stay the same.
Which Types Can Carry a Rating?
Google's Review snippet documentation limits star ratings to specific features: Book, Course list, Event, Local business, Movie, Product, Recipe and Software App. Reviews on the CreativeWorkSeason, CreativeWorkSeries, Episode and Game types are supported as well. Bookstore's book page is both a Product and a Book, so it passes through both doors.
What a review belongs to can be stated in two ways: either the review is nested under the reviewed item's review or aggregateRating property, or a standalone Review points to the item with itemReviewed. In November 2025 Google clarified the documentation to say you shouldn't use both ways for the same review. Bookstore uses nesting: the rating and the reviews sit directly inside the book node.
The required fields are short. A single Review needs author (under 100 characters, a valid person or organization name), reviewRating.ratingValue and the name of the reviewed item; datePublished is recommended. AggregateRating needs ratingValue and at least one of ratingCount and reviewCount. If no scale is given, 1–5 is assumed; with another scale you must write bestRating and worstRating. The decimal separator is a dot: 4.3, not 4,3.
This section is based on the official documentation.
Whose Reviews Count?
Google's guidelines set a few clear rules. Marked-up reviews must be immediately visible to visitors on the page: if you mark up a review, show its text and rating; if you use AggregateRating, show the average rating. A review must be about a specific item, not a category or a list. If you mark up several reviews, add the aggregate rating as well. Reviews and ratings collected from other websites must not be used.
Two guidelines were added in the last two years. In January 2025 Google recommended accepting only ratings that come with a review comment and the author's name; not required, but it shows readers the reasoning behind the rating. On 24 July 2026 a guideline on fake and undisclosed incentivized reviews was added: reviews not based on a genuine experience, and reviews written in exchange for money, discounts or free products without clearly saying so, belong neither on the page nor in the markup.
In Bookstore these rules turn into the data model. Every review has a moderation state (Pending, Approved, Rejected) and only approved ones count. The text of a rating is optional: a rating without text goes into the average but not into the review list. A review from a reader who got a free copy from Bookstore is flagged with IsIncentivized and shown on the page with the note "The reviewer received a free copy from Bookstore." The schema.org Review type has no field to carry that note, so Bookstore doesn't put these reviews into the markup one by one; they stay in the average because they are part of the average on the page as well. This is a cautious decision on a point the documentation doesn't spell out.
The Self-Serving Rule: Bookstore's Own Rating
The first piece of markup in the opening runs into this rule. Google applies an extra guideline when the review snippet is for a local business or an organization: if the reviewed entity controls the reviews about itself, its pages that use LocalBusiness or any other Organization type are not eligible for star ratings. The documentation's example is clear too: if a review about entity A is on A's own website, whether directly in structured data or through an embedded third-party widget such as Google Business or Facebook reviews, the result is the same.
Bookstore's "4.8" box is exactly this case: ratings about Bookstore on Bookstore's site. Keeping the box on the page is fine; the problem is writing it into the Organization markup. Reader reviews about Dune are different: the thing reviewed is the book, not Bookstore. We protect this distinction with a test:
[Theory]
[InlineData("/")]
[InlineData("/books/dune-paperback")]
public async Task Store_never_rates_itself(string path)
{
JsonLdPage page = await JsonLdPage.LoadAsync(factory.CreateClient(), path);
JsonElement store = Assert.Single(page.Nodes, n =>
n.TryGetProperty("@id", out JsonElement id) && id.GetString() == "https://bookstore.example/#organization");
// self-serving: reviews about Bookstore on Bookstore's own site are not eligible
Assert.False(store.TryGetProperty("aggregateRating", out _));
Assert.False(store.TryGetProperty("review", out _));
}
Calculating the Rating: One Query, One Rounding
We want the average rating from a single query instead of looping over a list in application code. Writing the query on IQueryable makes the same code run both on the sample data in memory and on Bookstore's BookstoreContext, which uses EF Core:
public sealed record RatingSummary(string Isbn, int RatingCount, int ReviewCount, double Average)
{
// Rounded once; the page and the JSON-LD both print this value
public decimal RatingValue => Math.Round((decimal)Average, 1, MidpointRounding.AwayFromZero);
}
public static class ReviewQueries
{
// Only moderated reviews count; written as IQueryable so EF Core can run it in SQL
public static IQueryable<RatingSummary> Summaries(this IQueryable<BookReview> reviews) =>
reviews
.Where(r => r.Status == ReviewStatus.Approved)
.GroupBy(r => r.Isbn)
.Select(g => new RatingSummary(
g.Key,
g.Count(),
g.Count(r => r.Text != null),
g.Average(r => r.Rating)));
}
RatingCount counts all approved ratings, ReviewCount only those with text. In Google's definition ratingCount is the total number of ratings on your site and reviewCount the number of people who left a review; keeping them separate also lets the page say "7 ratings, 4 reviews". EF Core translates this query into a single GROUP BY statement on SQL Server. In the test project we can look at the generated SQL with ToQueryString(), without connecting to a database:
SELECT [r].[Isbn], COUNT(*), COUNT(CASE
WHEN [r].[Text] IS NOT NULL THEN 1
END), AVG(CAST([r].[Rating] AS float))
FROM [Reviews] AS [r]
WHERE [r].[Status] = 1
GROUP BY [r].[Isbn]
The CAST inside AVG matters. According to the SQL Server documentation, AVG returns int for an int expression, so a hand-written SELECT AVG(Rating) returns an average of 4.29 as 4. EF Core adds the cast itself because the result of Average is a double. With Dapper or raw SQL you have to write it yourself. This check in the test project pins down that the query is translated the way we expect:
public void Summary_query_runs_in_sql_server_as_one_grouped_statement()
{
DbContextOptions<ReviewsContext> options = new DbContextOptionsBuilder<ReviewsContext>().UseSqlServer().Options;
using ReviewsContext db = new(options);
// ToQueryString translates the query without opening a connection
string sql = db.Reviews.Summaries().ToQueryString();
TestContext.Current.TestOutputHelper?.WriteLine(sql);
Assert.Contains("GROUP BY [r].[Isbn]", sql);
Assert.Contains("AVG(CAST([r].[Rating] AS float))", sql);
}
Rounding is the second trap. When the average sits exactly at the midpoint, different paths in .NET give different results:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
double average = (5 + 4 + 4 + 4) / 4.0;
Print("average", average);
Print("average.ToString(\"F1\")", average.ToString("F1"));
Print("Math.Round(average, 1)", Math.Round(average, 1));
Print("Math.Round(.., AwayFromZero)", Math.Round((decimal)average, 1, MidpointRounding.AwayFromZero));
static void Print(string label, object value) => Console.WriteLine($"{label,-30}{value}");
$ dotnet run samples/rating-rounding.cs
average 4.25
average.ToString("F1") 4.2
Math.Round(average, 1) 4.2
Math.Round(.., AwayFromZero) 4.3
According to Microsoft's formatting documentation, since .NET Core 2.1 standard formats such as ToString("F1") round a midpoint value so that the last digit is even; the default for Math.Round is MidpointRounding.ToEven as well. That is why code that writes "4.2" on the page and 4.3 in the markup comes about so easily. The fix is to round the value once, in one place (RatingSummary.RatingValue), and have both the page and the markup use it. Which rule you choose doesn't matter; what matters is that there is only one.
Summary and List Must Be Cached Together
We cache the average so it isn't recalculated on every page view. The common mistake here is caching the average and fetching the review list fresh on every request: a newly approved review appears in the list while the counter stays stale for five minutes, and the page says "4 reviews" while showing five. Bookstore keeps both in the same cache entry:
// Summary and visible reviews are cached together, so their counts always agree
public sealed record ReviewSection(RatingSummary? Summary, IReadOnlyList<BookReview> Latest);
public sealed class ReviewService(IReviewSource source, IMemoryCache cache)
{
public ReviewSection ForBook(string isbn) =>
cache.GetOrCreate($"reviews:{isbn}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
IQueryable<BookReview> forBook = source.Reviews.Where(r => r.Isbn == isbn);
RatingSummary? summary = forBook.Summaries().SingleOrDefault();
List<BookReview> latest = forBook
.Where(r => r.Status == ReviewStatus.Approved && r.Text != null)
.OrderByDescending(r => r.CreatedAt)
.Take(3)
.ToList();
return new ReviewSection(summary, latest);
})!;
}
This is a Bookstore decision, not a Google rule; but the shortest way to meet Google's "the aggregate rating must be visible on the page" requirement is to produce both from the same object.
Markup: Two Properties Added to the Book Node
SiteGraph.Book now also takes the ReviewSection. Without reviews neither property is written at all:
// Incentivized reviews stay visible with their note, but schema.org has no field for the note
private static List<BookReview> MarkupReviews(ReviewSection reviews) =>
[.. reviews.Latest.Where(r => !r.IsIncentivized)];
Offers: offer,
// no reviews, no rating: never publish ratingValue 0 or an empty list
AggregateRating: reviews.Summary is { } summary
? new AggregateRatingNode(summary.RatingValue, summary.RatingCount, summary.ReviewCount)
: null,
Review: MarkupReviews(reviews) is { Count: > 0 } marked
? [.. marked.Select(r => new ReviewNode(
new NamedNode("Person", r.AuthorName),
r.CreatedAt.ToString("yyyy-MM-dd"),
r.Text!,
new RatingNode(r.Rating)))]
: null);
The result on Dune's EN page:
{
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.3,
"ratingCount": 7,
"reviewCount": 4,
"bestRating": 5,
"worstRating": 1
},
"review": [
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Linus T."
},
"datePublished": "2026-11-03",
"reviewBody": "The appendices are more interesting than the ending.",
"reviewRating": {
"@type": "Rating",
"ratingValue": 3,
"bestRating": 5
}
},
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Alan T."
},
"datePublished": "2026-10-09",
"reviewBody": "Slow first hundred pages, then impossible to put down.",
"reviewRating": {
"@type": "Rating",
"ratingValue": 4,
"bestRating": 5
}
}
]
}
The page shows three reviews and the markup has two: the difference is the incentivized review. ratingValue is 4.3 and comes from the same value as the "4.3 / 5 · 7 ratings, 4 reviews" text on the page. The review rejected as spam and the "bent cover" complaint waiting for moderation are on neither the page nor the markup.
Tests
The tests from the previous post are still there; the new test class compares the rating markup with the page:
[Theory]
[InlineData("/books/dune-paperback", "tr")]
[InlineData("/en/books/dune-paperback", "en")]
public async Task Rating_markup_matches_what_the_reader_sees(string path, string lang)
{
JsonLdPage page = await JsonLdPage.LoadAsync(factory.CreateClient(), path);
JsonElement product = page.Single("Product");
JsonElement rating = product.GetProperty("aggregateRating");
string visible = page.Document.QuerySelector(".rating-summary")!.TextContent;
Assert.Equal(visible, BookTexts.RatingSummary(
rating.GetProperty("ratingValue").GetDecimal(),
rating.GetProperty("ratingCount").GetInt32(),
rating.GetProperty("reviewCount").GetInt32(), lang));
// every review in the markup must be readable on the page, with its author
string[] texts = [.. page.Document.QuerySelectorAll(".review-text").Select(e => e.TextContent)];
string[] authors = [.. page.Document.QuerySelectorAll(".review-author").Select(e => e.TextContent)];
foreach (JsonElement review in product.GetProperty("review").EnumerateArray())
{
Assert.Contains(review.GetProperty("reviewBody").GetString(), texts);
Assert.Contains(review.GetProperty("author").GetProperty("name").GetString(), authors);
}
}
The other tests check that rejected and pending reviews stay out of the page and the markup, that the hardcover edition without reviews publishes no rating at all, and that the store node doesn't rate itself. The rounding test pins down that 4.25 becomes 4.3 for a four-rating sample (5, 4, 4, 4).
$ dotnet test --project Bookstore.Web.Tests
Test run summary: Passed!
total: 21
failed: 0
succeeded: 17
skipped: 4
Production Notes
Stop duplicate reviews when they are written. In a system where one customer can write five reviews for the same book, deduplicating at count time complicates every query. Bookstore has a unique index on (Isbn, CustomerId); the ReviewsContext in the test project shows it at model level.
Think about moderation before markup. Google's guideline forbids fake reviews but doesn't say how to filter them out; that's the application's job. Pending reviews must not be counted and rejected ones must not appear anywhere. Preventing review spam is a separate security topic; the input validation posts in the Web Security (OWASP Top 10) series are the starting point.
When you change the source of the rating, change the markup too. The day you move reviews to a third-party service, Google's "don't aggregate reviews from other websites" rule may come into play; make sure the ratings are collected on your own site.
You may see a warning in the Rich Results Test. Google's product snippet documentation says the tool may report a warning if you provide only offers without review or aggregateRating; for the hardcover edition without reviews this is expected and is not to be silenced with a made-up rating.
Common Pitfalls
Putting your own store rating on Organization. Because of the self-serving rule it isn't eligible for star ratings; the result is the same if the rating comes from a widget.
Using view or sales counts instead of the number of reviews. reviewCount counts people who left a review; how many times the page was opened or how many copies were sold doesn't go into this field.
Writing ratingValue: 0 when there are no reviews. Zero is not a rating, it's missing data; the property should not be written at all.
Adding pros and cons to a store page. Google's product snippet documentation enables the pros and cons appearance only for editorial product reviews; merchant product pages and customer reviews are outside it.
A Small Experiment
★☆☆ Add a fifth review with text to SampleReviewSource and verify that the three latest reviews on the page change and that reviewCount is 5.
★★☆ Change RatingSummary.RatingValue to MidpointRounding.ToEven. Which test fails? Why does the consistency test between the page and the markup still pass?
★★★ Rewrite ReviewService asynchronously to work with a BookstoreContext instead of IReviewSource (SingleOrDefaultAsync, ToListAsync), and measure on a SQL Server started with Testcontainers how the average changes without the CAST.
The book page now describes its price, its stock and what readers think. What's still missing is the store itself: shipping cost, delivery time and return conditions. In the next post we define them once at the Organization level and look at which source Google gives priority to.