DEV Community

Datawright Labs
Datawright Labs

Posted on

App Store top charts in Python: Apple's free RSS feeds, genres, countries and ratings (2026)

If you want to know which apps are climbing the App Store, you don't need a paid app-intelligence subscription to get started. Apple publishes the top charts as free, keyless JSON feeds, and the iTunes Lookup API fills in ratings, versions and update dates. The catch is that there are two feed systems, they don't cover the same charts, and neither is well documented.

This post covers what each feed returns, the limits I ran into when testing on October 21, 2026, and a script that writes daily snapshots to a CSV you can diff over time.

The two feeds

1. The legacy iTunes RSS feed (still live):

https://itunes.apple.com/{country}/rss/topfreeapplications/limit=100/genre=6015/json
Enter fullscreen mode Exit fullscreen mode
  • Charts: topfreeapplications, toppaidapplications, topgrossingapplications, and the iPad versions (topfreeipadapplications, toppaidipadapplications, topgrossingipadapplications).
  • Genre filter: yes, with /genre={id}. 6015 is Finance, 6014 Games, 6007 Productivity, 7001-7019 are the game subgenres.
  • Fields: name, developer, category, price, release date, icon and store URL.
  • Max 100 entries. I asked for limit=200 and got 100 back.

2. The newer marketing-tools feed:

https://rss.marketingtools.apple.com/api/v2/{country}/apps/top-free/100/apps.json
Enter fullscreen mode Exit fullscreen mode
  • Cleaner JSON (feed.results[] with id, name, artistName, genres, releaseDate) and an updated timestamp.
  • Only top-free and top-paid. top-grossing returned a 404 when I tested it, and there's no genre filter.

So if you care about top grossing (usually the most interesting chart, because it's a revenue proxy) or about one category, the legacy feed is the one that works.

Adding ratings and versions

Neither feed includes ratings. The iTunes Lookup API does, and it accepts many ids at once:

https://itunes.apple.com/lookup?id=407558537,6448311069&country=us&entity=software
Enter fullscreen mode Exit fullscreen mode

You get averageUserRating, userRatingCount, version, currentVersionReleaseDate, file size, content rating and more. Pass the same country as the chart, because ratings are per storefront.

The script

Standard library only. It pulls each country and chart combination, enriches each entry with Lookup data, and appends rows to a CSV with the date, so running it daily (cron, GitHub Actions) builds a rank history.

"""App Store top charts in Python: Apple's public RSS feeds + iTunes Lookup.
Usage: python3 app_store_charts.py --countries us,gb,ca --charts top-free,top-grossing --genre 6015 --out charts.csv
Append each day's run to the same CSV and diff ranks between dates to track movers."""
import argparse, csv, datetime, json, time, urllib.request

FEEDS = {
    'top-free': 'topfreeapplications', 'top-paid': 'toppaidapplications', 'top-grossing': 'topgrossingapplications',
    'top-free-ipad': 'topfreeipadapplications', 'top-paid-ipad': 'toppaidipadapplications',
    'top-grossing-ipad': 'topgrossingipadapplications',
}

def get_json(url, tries=4):
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (chart-tracker)'})
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.load(r)
        except Exception as e:
            if i == tries - 1:
                raise
            time.sleep(2 ** i)

def chart(country, name, genre=None, limit=100):
    url = f'https://itunes.apple.com/{country}/rss/{FEEDS[name]}/limit={limit}'
    if genre:
        url += f'/genre={genre}'
    entries = get_json(url + '/json').get('feed', {}).get('entry', [])
    if isinstance(entries, dict):  # a 1-entry feed is an object, not a list
        entries = [entries]
    rows = []
    for rank, e in enumerate(entries, 1):
        rows.append({
            'rank': rank,
            'app_id': e['id']['attributes']['im:id'],
            'name': e['im:name']['label'],
            'developer': e['im:artist']['label'],
            'category': e['category']['attributes']['label'],
            'price': e['im:price']['attributes']['amount'],
            'release_date': e.get('im:releaseDate', {}).get('label', '')[:10],
        })
    return rows

def lookup(ids, country):
    """Ratings, version and size for up to ~150 app ids per call."""
    out = {}
    for i in range(0, len(ids), 150):
        url = f'https://itunes.apple.com/lookup?id={",".join(ids[i:i+150])}&country={country}&entity=software'
        for r in get_json(url).get('results', []):
            out[str(r['trackId'])] = r
    return out

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--countries', default='us')
    ap.add_argument('--charts', default='top-free')
    ap.add_argument('--genre', type=int)
    ap.add_argument('--out', default='charts.csv')
    a = ap.parse_args()
    today = datetime.date.today().isoformat()
    fields = ['date', 'country', 'chart', 'genre', 'rank', 'app_id', 'name', 'developer', 'category',
              'price', 'release_date', 'rating', 'rating_count', 'version', 'last_updated']
    n = 0
    with open(a.out, 'a', newline='') as f:
        w = csv.DictWriter(f, fieldnames=fields)
        if f.tell() == 0:
            w.writeheader()
        for c in a.countries.split(','):
            for ch in a.charts.split(','):
                rows = chart(c, ch, a.genre)
                meta = lookup([r['app_id'] for r in rows], c)
                for r in rows:
                    m = meta.get(r['app_id'], {})
                    w.writerow({**r, 'date': today, 'country': c, 'chart': ch, 'genre': a.genre or '',
                                'rating': round(m.get('averageUserRating', 0), 2), 'rating_count': m.get('userRatingCount', ''),
                                'version': m.get('version', ''), 'last_updated': m.get('currentVersionReleaseDate', '')[:10]})
                n += len(rows)
                print(f'{c} {ch}: {len(rows)} apps, {len(meta)} with lookup data')
    print(f'wrote {n} rows to {a.out}')

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Test run on October 21, 2026: US, UK and Canada, top free plus top grossing, took 16 seconds for 600 rows, and every app had Lookup data. The US Finance top free chart (--genre 6015) took 2.5 seconds: Capital One, Kalshi, the Treasury's Trump Accounts app, PayPal and Cash App were the top five.

Things that will bite you

  • A one-entry feed is an object, not a list. Small genre charts in small storefronts can return a single entry dict. The script normalizes it.
  • Charts update a few times a day. Pick a fixed time for your snapshot, or you'll be comparing a morning chart with an evening one.
  • Rank is position in the list. The feed has no rank field, so count it yourself, and don't sort the entries before you do.
  • Country codes are storefronts, not languages. gb is the UK, not uk. Names and prices come back localized.
  • Apple deprecates things quietly. The legacy feed has outlived several "replacements" already, but wrap your calls in retries and alert on empty charts, so a silent change doesn't leave gaps in your history.

To track movers, load two dates from the CSV into pandas, merge on country, chart and app_id, and subtract the ranks. Apps present today but not yesterday are your new entries.

Hosted version

Disclosure: I'm with Datawright Labs, and we packaged this as a hosted tool on Apify: App Store Top Charts Scraper. It covers all six iPhone and iPad charts, any country and genre, adds the rank change since the previous run, and can run on a schedule. It costs $1 per 1,000 chart entries ($0.10 for a top-100 chart), so Apify's free monthly credit covers daily tracking of a few charts. The script above does the core job too, so use whichever fits.

Top comments (0)