On H&M's US site, the median men's T-shirt style lists at $24.99. The median women's T-shirt style lists at $19.99. For hoodies the gap is $44.99 against $29.99.
That is the opposite of what I expected. The "pink tax" (women paying more for comparable products) is well documented: a 2015 New York City study of 794 products found women's versions cost about 7% more on average, and 8% more for adult clothing. So I scraped H&M's catalogue with Python to see whether a fast-fashion retailer follows the pattern. For jeans, T-shirts and hoodies, it does not. Men pay more for all three, and the reason turns out to be the shape of the range more than the price tags.
The data
I used the H&M API, which returns H&M's search results as JSON. All of it came from a single endpoint, /product-search, which I called with a search word (jeans, t-shirt, hoodie), languageCode=en_us for the US site, pageSize=60, and a filters string that pins two of H&M's own facets: customerGroups (Woman or Man) and productTypes. I paged through every result for all six combinations on 24 October 2026: 37 pages, 2,114 listings.
A listing is one colour of one garment. Its id is a 10-digit article number, and the first 7 digits identify the style, so the 2,114 listings are 818 styles. Each listing has prices (a whitePrice, which is the regular price, and a redPrice when it is on sale), mainCatCode (H&M's internal category, such as men_jeans_baggy), and sizes, where every size carries a stock value.
The code
To run it, put a key from RapidAPI (there is a free plan) into RAPIDAPI_KEY and install requests and pandas. Each page is saved to hm_cache/ the first time, so the 37 calls are spent once and re-runs are free.
import json import os import re from pathlib import Path import pandas as pd import requests HOST = "h-m-hennes-mauritz1.p.rapidapi.com" CACHE = Path("hm_cache") TYPES = {"jeans": "Jeans", "t-shirt": "T-shirt", "hoodie": "Shirt"} # H&M files hoodies under "Shirt" def search(keyword, group, page): """One page of 60 results, cached on disk so a re-run costs no calls.""" file = CACHE / f"{keyword}_{group}_p{page:02d}.json" if file.exists(): return json.loads(file.read_text()) r = requests.get( f"https://{HOST}/product-search", params={"query": keyword, "languageCode": "en_us", "pageSize": 60, "page": page, "filters": f"customerGroups:{group}||productTypes:{TYPES[keyword]}"}, headers={"x-rapidapi-host": HOST, "x-rapidapi-key": os.environ["RAPIDAPI_KEY"]}, timeout=90, ) r.raise_for_status() CACHE.mkdir(exist_ok=True) file.write_text(r.text) return r.json() rows, sizes = [], [] for keyword in TYPES: for group in ["Woman", "Man"]: page, last = 1, 1 while page <= last: data = search(keyword, group, page) last = data["pagination"]["totalPages"] for p in data["searchHits"]["productList"]: price = {x["priceType"]: x["price"] for x in p["prices"]} rows.append({"type": keyword, "group": group, "id": p["id"], "style": p["id"][:7], # article id = style + colour "name": p["productName"], "category": p["mainCatCode"], "regular": price["whitePrice"], "sale": price.get("redPrice")}) for s in p["sizes"]: sizes.append({"id": p["id"], "label": s["label"], "out": s["stock"] == 0, "on_sale": "redPrice" in price}) page += 1 df = pd.DataFrame(rows).drop_duplicates(["type", "group", "id"]) sz = pd.DataFrame(sizes).drop_duplicates(["id", "label"]) # Like for like: drop polos, multipacks, sportswear and maternity (and stray non-tees) skip = (df["category"].str.contains("polo|multipack|maternity|^sportswear") | df["name"].str.contains(r"polo|\d-pack", case=False) | ((df["type"] == "t-shirt") & ~df["category"].str.contains("tops|tshirt"))) core = df[~skip] styles = core.groupby(["type", "group", "style"])["regular"].median() medians = styles.groupby(["type", "group"]).median().unstack() medians["men_pay_more_%"] = ((medians["Man"] / medians["Woman"] - 1) * 100).round(1) print(f"{len(df)} articles, {df['style'].nunique()} styles, {len(core)} kept for the comparison") print(medians.to_string()) print(f"\nSize slots with stock 0: {sz['out'].mean():.1%} overall, " f"{sz[sz['on_sale']]['out'].mean():.1%} on sale items") tops = sz[sz["id"].isin(df.loc[df["type"] != "jeans", "id"])] print(tops.groupby("label")["out"].mean().reindex(["XS", "S", "M", "L", "XL", "XXL"]).round(3).to_string()) Two choices matter. First, the comparison is per style, not per listing: a women's basic tee that comes in 20 colours would otherwise count 20 times and drag the women's median down. Every colour of a style had the same price in this data, so the style price is unambiguous. Second, I left out what is not comparable across the two groups. The men's "T-shirt" results include 183 polo shirts, 91 sportswear tops and 34 multipacks; the women's results include maternity jeans and tops. That leaves 1,685 listings in 657 styles.
The first lines it prints:
2114 articles, 818 styles, 1685 kept for the comparison group Man Woman men_pay_more_% type hoodie 44.99 29.99 50.0 jeans 49.99 44.99 11.1 t-shirt 24.99 19.99 25.0 Men pay more for all three
The gap holds however I cut it. Per listing instead of per style, it gets wider: men's T-shirts $22.99 against $14.99, jeans $49.99 against $39.99. The most common price point tells the same story for jeans ($49.99 for men, on 77 listings; $39.99 for women, on 200) and hoodies ($49.99 against $24.99). The one exception is the T-shirt: $19.99 is the single most common price in both ranges.
| Garment | Group | Listings | Styles | Median style | Cheapest | Styles under $20 | On sale |
|---|---|---|---|---|---|---|---|
| T-shirts | Women | 363 | 143 | $19.99 | $4.99 | 72.7% | 5.8% |
| T-shirts | Men | 348 | 117 | $24.99 | $7.99 | 25.6% | 8.6% |
| Hoodies | Women | 31 | 13 | $29.99 | $19.99 | 7.7% | 0% |
| Hoodies | Men | 114 | 50 | $44.99 | $24.99 | 0% | 19.3% |
| Jeans | Women | 662 | 286 | $44.99 | $19.99 | 0.7% | 6.6% |
| Jeans | Men | 167 | 48 | $49.99 | $34.99 | 0% | 8.4% |
The hoodie number needs a warning. The women's search returned only 33 hoodies, 13 styles once sportswear is out, so a few styles move that median. T-shirts and jeans have hundreds of styles on each side and are the numbers I would trust.
Why: women get a cheap tier, men get multipacks
This is not H&M charging men more for the same garment. The two ranges contain different garments, and I found no product sold in both that I could price both ways. What differs is the price ladder each shopper is offered.
The women's T-shirt range has a dedicated basics category, ladies_basics_tops_shortsleeve, with 46 styles at a median of $12.99 and the cheapest tee in the data, a "Fitted T-shirt" at $4.99. Nearly three in four women's T-shirt styles cost under $20; for men it is one in four. The men's results have no basics category at all. Men's cheap tees are sold in packs instead: 34 of the men's T-shirt listings are multipacks (not counting packs of polos), such as a "5-pack Regular Fit T-shirts" at $39.99, which is $8 a shirt. Buy the pack and the men's price per shirt drops to women's basics levels. Buy one shirt and it does not.
Jeans show the other half of the story: choice. Women get 286 styles of jeans, men get 48, six times fewer. The women's range reaches down to $19.99; the cheapest men's pair is $34.99. A wider range fills more price points, and most of the extra women's styles sit at the lower ones.
A third of all sizes are already gone
Every size in the response has a stock value. I only ever saw two: 2 and 0. Of 16,673 size slots across all 2,114 listings, 34.3% were 0. I read 0 as "not available in that size", which fits two checks: no listing in the data had every size at 0 (a fully sold-out item drops out of search), and the share of zeros rises sharply on sale items.
Small sizes are missing most often. In tops, S is the most often missing size (37.5% of slots), then XS (35.8%) and M (34.6%), while XXL is the easiest to find (22.0%). The edges of the range are thinner still: women's XXS was out on 70.1% of its 67 slots and men's 3XL on 51.7% of 174. Women's jeans follow the same slope, from 54.7% missing in size 0 to 23.3% in size 18 (the few size 24 and 26 slots are thin again).
Stock levels like these say nothing about demand on their own: a size can be missing because it sold well or because fewer were made. But if you are scraping H&M to track availability, sizing is where the signal is.
On sale, three in four sizes are gone
Only 189 of the 2,114 listings were on sale (redPrice present), but their sizes are mostly gone: 75.5% of size slots at 0, against 30.0% at full price, and 172 of the 189 had at least half their sizes missing. The discounts are deep, with a median of 40% to 52% off depending on the garment, but they are clearance of broken size runs rather than offers on the full range.
Men's listings were on sale more often in every garment: 8.6% of the men's T-shirts in the comparison against 5.8% of the women's, 19.3% of men's hoodies against none of the women's, and 8.4% of men's jeans against 6.6%. So the men's premium shrinks a little for anyone who shops the sale and wears whatever size is left.
What tripped me up in the data
There is no "Hoodie" product type. Filtering productTypes:Hoodie returned zero results for both groups. The facets in that empty response showed where the hoodies were: H&M files them under Shirt (125 men's, 33 women's). Read the facets block before you trust a filter value that seems obvious.
isMultipack is always false. All 47 listings named "3-pack", "5-pack" and so on had isMultipack: false, and so did every other listing. Catch packs by name or by mainCatCode (men_tshirtstanks_multipacks), and divide by the pack size before you compare anything.
stockState is always "Available". Every one of the 2,114 listings said so, including those with most sizes at 0. Availability lives in sizes[].stock, not in availability.stockState.
A listing is a colour, not a product. One style had 26 colour listings. If you count listings, your "most common price" is really the price of whichever style comes in the most colours. Group by the first 7 digits of id first.
The price facet reports sale prices. The priceRange facet for women's jeans said the range starts at $8.49, but no women's jeans had a regular price below $19.99. In all six searches the facet minimum matched the lowest current price, which is the redPrice wherever anything was on sale. Use whitePrice for list prices and redPrice for what a shopper pays today.
A "T-shirt" search is not only T-shirts. The men's productTypes:T-shirt results were 28% polo shirts by count, and the women's results included a few jumpers and maternity bottoms. mainCatCode is the more reliable label: it is H&M's own category path and it is present on every listing.
Sizes are not comparable across groups. Men's jeans use waist and length pairs (32/32), 42 distinct labels in all and a median of 18 sizes per listing; women's jeans mostly use US numbers (0 to 26), with a median of 11 sizes. Compare availability within a sizing system, not across them.
One listing appeared twice. The men's T-shirt search reported 657 hits and returned 656 distinct ids across its pages. Deduplicate on id after paging.
What I would do next
Three things. Run the same six searches weekly, which would show whether the men's sale share is a constant or a moment in the season. Repeat it in other markets through languageCode (en_gb, de_de), to see whether the women's basics tier exists everywhere. And call /supplier-details for a sample of styles from each side, to see whether the cheap women's basics and the men's multipacks come from different factories.
The data came from the H&M API. I wrote about the same kind of catalogue data for beauty retail in a LinkedIn article on Sephora product and price data, and for a custom fashion dataset, get in touch.




