Learn · Developers

Create a bar chart race video from a CSV with Python

Contents

A bar chart race shows a ranking changing over time. Each period, the bars grow or shrink to their new values and swap places as one overtakes another, so the viewer sees who led when, and who caught up.

This guide turns a CSV into a bar chart race MP4 with Python and Shotstack’s video editing API. Python reads the data and works out the rankings. One html5 clip animates the chart with GSAP, and Shotstack renders it, so there is no local renderer to install.

We checked every bar’s rank and width on the rendered frames.

TL;DR

  • Use a wide CSV: one row per period, one column per bar.
  • Python ranks the bars in each period and works out each bar’s width as a share of that period’s leader.
  • The whole chart is one html5 asset. The data goes into its JavaScript as a JSON literal, and one GSAP timeline animates it.
  • The render request is under 5 KB. One clip per frame would run into the Edit API’s 390 KB request limit.
  • In our render, every bar sat at its planned rank at the end of all 8 periods, and every width was within 2.2 px of the plan.

What the example builds

The example charts made-up monthly sales for six stores from January to August. All six bars stay on screen the whole time, and they swap places as the leader changes. The output is a 1920 × 1080, 13 second MP4:

The values are sample data, and the chart’s title says so. Each month, the leader’s bar fills the full width and the others are drawn as a share of it.

Save the sample data as sales.csv

Save this as sales.csv:

csv
month,Austin,Boston,Chicago,Denver,Miami,Seattle
Jan,120,180,150,90,110,130
Feb,160,175,155,120,140,150
Mar,210,190,160,150,175,165
Apr,230,205,185,205,200,170
May,240,215,220,250,215,190
Jun,255,230,250,280,260,200
Jul,270,240,275,300,310,225
Aug,300,250,290,310,335,260

The script expects a wide CSV:

  • The first column holds the period labels, here Jan to Aug.
  • Every other column is one bar, named by its header.
  • Every row needs a number for every bar.

The script keeps the rows in file order. It doesn’t parse or sort dates, so put the periods in the order you want them shown. If your data is long, with one row per period and store, pivot it to this shape first.

Make a bar chart race video from a CSV with Python

The script uses only the Python standard library, so there is nothing to install. Save it as bar_chart_race.py next to sales.csv, set SHOTSTACK_API_KEY to the API key from your Shotstack dashboard, and run it with Python 3.

New to the API? Start with rendering your first video with the Shotstack API.

Python
import csv
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.shotstack.io/edit/v1/render"
CSV_FILE = "sales.csv"  # first column: period, other columns: one per bar
TITLE = "Monthly sales by store, $k (sample data)"
COLORS = ["#25D3D0", "#F25F5C", "#FFB400", "#6A4C93", "#1982C4", "#8AC926"]
LEAD, STEP, HOLD = 0.5, 1.5, 2.0  # seconds: before the first move, per period, at the end
ROW, BAR_MAX = 120, 1200  # row height and the leader's bar width, in pixels

HTML = '<div id="stage"><h1>{title}</h1><div id="period"></div></div>'
CSS = """html,body{margin:0;width:1920px;height:1080px;overflow:hidden;background:#fff;
font-family:system-ui,sans-serif;color:#1a1a2e}
h1{position:absolute;left:120px;top:60px;margin:0;font-size:56px}
#period{position:absolute;right:120px;bottom:80px;font-size:120px;font-weight:800;color:#c8c8d0}
.row{position:absolute;left:120px;top:0;height:96px;width:1700px}
.name{position:absolute;width:220px;line-height:96px;font-size:36px;font-weight:700}
.bar{position:absolute;left:240px;height:96px;border-radius:8px}
.val{position:absolute;left:100%;margin-left:16px;line-height:96px;font-size:36px}"""
JS = """const D = __DATA__;
const tl = gsap.timeline();
const stage = document.getElementById('stage');
const period = document.getElementById('period');
D.names.forEach((name, i) => {
  const [rank, width, value] = D.steps[0].bars[name];
  const row = document.createElement('div');
  row.className = 'row';
  row.innerHTML = `<div class="name">${name}</div><div class="bar"><span class="val"></span></div>`;
  stage.appendChild(row);
  const bar = row.querySelector('.bar'), val = row.querySelector('.val');
  bar.style.background = D.colors[i % D.colors.length];
  gsap.set(row, {y: 200 + rank * D.row});
  gsap.set(bar, {width: width * D.barMax});
  val.textContent = value.toLocaleString('en-US');
  const counter = {v: value};
  D.steps.slice(1).forEach((s, k) => {
    const [r, w, v] = s.bars[name], at = D.lead + k * D.step;
    tl.to(row, {y: 200 + r * D.row, duration: D.step, ease: 'power1.inOut'}, at);
    tl.to(bar, {width: w * D.barMax, duration: D.step, ease: 'none'}, at);
    tl.to(counter, {v: v, duration: D.step, ease: 'none',
      onUpdate: () => { val.textContent = Math.round(counter.v).toLocaleString('en-US'); }}, at);
  });
});
const clock = {i: 0};  // the label changes halfway through each move
period.textContent = D.steps[0].label;
tl.to(clock, {i: D.steps.length - 1, duration: (D.steps.length - 1) * D.step, ease: 'none',
  onUpdate: () => { period.textContent = D.steps[Math.round(clock.i)].label; }}, D.lead);"""


def load(path):
    with open(path, newline="", encoding="utf-8") as f:
        rows = list(csv.reader(f))
    names, steps = rows[0][1:], []
    for line, row in enumerate(rows[1:], start=2):
        if len(row) != len(names) + 1:
            sys.exit(f"{path} line {line}: expected {len(names) + 1} columns")
        try:
            values = [float(x) for x in row[1:]]
        except ValueError:
            sys.exit(f"{path} line {line}: every value must be a number")
        if min(values) < 0:
            sys.exit(f"{path} line {line}: values can't be negative")
        steps.append((row[0], dict(zip(names, values))))
    return names, steps


def race(names, steps):
    """For each period: every bar's rank, width (share of the leader) and value."""
    out = []
    for label, values in steps:
        order = sorted(names, key=lambda n: (-values[n], n))  # ties: alphabetical
        top = values[order[0]] or 1
        bars = {n: [order.index(n), round(values[n] / top, 3), values[n]] for n in names}
        out.append({"label": label, "bars": bars})
    return out


def build_edit(names, steps):
    data = {
        "names": names,
        "colors": COLORS,
        "steps": race(names, steps),
        "row": ROW,
        "barMax": BAR_MAX,
        "lead": LEAD,
        "step": STEP,
    }
    asset = {
        "type": "html5",
        "html": HTML.format(title=TITLE),
        "css": CSS,
        "js": JS.replace("__DATA__", json.dumps(data)),
    }
    length = LEAD + (len(steps) - 1) * STEP + HOLD
    clip = {"asset": asset, "start": 0, "length": length}
    return {
        "timeline": {"tracks": [{"clips": [clip]}]},
        "output": {"format": "mp4", "size": {"width": 1920, "height": 1080}},
    }


def call(url, body=None):
    data = json.dumps(body).encode() if body else None
    headers = {"x-api-key": os.environ["SHOTSTACK_API_KEY"], "Content-Type": "application/json"}
    try:
        with urllib.request.urlopen(urllib.request.Request(url, data, headers)) as response:
            return json.load(response)["response"]
    except urllib.error.HTTPError as error:
        sys.exit(f"HTTP {error.code}: {error.read().decode()[:300]}")


if __name__ == "__main__":
    names, steps = load(CSV_FILE)
    edit = build_edit(names, steps)
    if "--dry-run" in sys.argv:
        print(json.dumps(race(names, steps)[-1], indent=2))
        sys.exit()
    if not os.environ.get("SHOTSTACK_API_KEY"):
        sys.exit("Set SHOTSTACK_API_KEY first.")
    print(
        f"{len(names)} bars, {len(steps)} periods, {edit['timeline']['tracks'][0]['clips'][0]['length']}s"
    )
    render_id = call(API, edit)["id"]
    print("Render queued:", render_id)
    while True:
        time.sleep(5)
        status = call(f"{API}/{render_id}")
        if status["status"] == "done":
            print(status["url"])
            break
        if status["status"] == "failed":
            sys.exit(f"Render failed: {status.get('error')}")

Run python bar_chart_race.py. It prints the number of bars and periods and the video length, then the render ID and the URL of the finished video:

Text
6 bars, 8 periods, 13.0s
Render queued: 0647a0e8-af82-45b6-b36b-7924a11cbbd3
https://shotstack-api-v1-output.s3-ap-southeast-2.amazonaws.com/35tqpmb0ya/0647a0e8-af82-45b6-b36b-7924a11cbbd3.mp4

Run python bar_chart_race.py --dry-run to check the data without rendering. It needs no API key. It prints the last period’s ranking: for each bar, its rank (0 is first place), its width as a share of the leader, and its value.

How the script works

  • load() reads the CSV. It checks that every row has one value per bar, that every value is a number and that none is negative, and stops with the line number when one fails. A blank cell is an error, not a zero.
  • race() ranks the bars in each period, highest value first, and works out each bar’s width as its value divided by the period’s leader.
  • build_edit() puts the rankings into the page’s JavaScript as JSON and wraps the HTML, CSS and JavaScript in one html5 clip. The clip’s length comes from the timing constants.
  • call() and the main block submit the edit to /edit/v1/render and poll every 5 seconds until the video is ready.

How values and ranks become motion

Ranks. Each period is ranked on its own, highest value first. Ties sort alphabetically: in May, Boston and Miami both have 215, so Boston takes the fourth row and Miami the fifth.

Widths. Each bar’s width is its value divided by that period’s leader, times BAR_MAX (1,200 px). The leader always fills 1,200 px. This is a moving scale, so two bars of the same width in different months don’t have the same sales.

Movement. Between two periods, each row moves to its new vertical position, 200 px plus its rank times ROW (120 px), with a power1.inOut ease. The bar width and the value label change linearly over the same 1.5 seconds.

In-between frames. The numbers shown during a move are interpolated, not extra data. The month label switches halfway through each move. “Feb” appears at 1.25 seconds, while February’s values are only reached at 2 seconds.

Timing. The video lasts LEAD + (periods - 1) × STEP + HOLD: 0.5 + 7 × 1.5 + 2 = 13 seconds for eight months. Every pair of periods gets the same 1.5 seconds, whatever the gap between their labels.

Why the chart is one html5 clip

The html5 asset renders a web page with HTML, CSS and JavaScript, with GSAP, anime.js, D3 and Lottie already loaded. The page can’t fetch anything while it renders, so the script writes the rankings into the JavaScript as a JSON literal.

The renderer captures each frame by seeking the page to that moment, and a GSAP timeline is seekable, so one timeline holds every move. The value labels and the month label change through onUpdate tweens, the pattern the html5 docs recommend for content that changes over time. Timers such as setTimeout would capture frozen frames.

This keeps the request small: 4,779 bytes for the example. Drawing the chart as a separate clip for every frame would need hundreds of clips and quickly hits the Edit API’s 390 KB request limit. For more on bringing an existing animation into a video, see our guide to converting an HTML animation to MP4.

What the rendered frames show

We extracted a frame at the end of each period from the production render (1920 × 1080, 13 seconds). Then we found each bar by its color and measured its row and width.

MonthLeaderBars at their planned rankLargest width gap from the plan
JanBoston6 of 61.2 px
FebBoston6 of 62.2 px
MarAustin6 of 62.2 px
AprAustin6 of 62.2 px
MayDenver6 of 62.0 px
JunDenver6 of 61.2 px
JulMiami6 of 61.6 px
AugMiami6 of 61.2 px
  • Every bar was at its planned rank in every month, including the May tie.
  • The largest width gap anywhere was 2.2 px, out of a 1,200 px bar.
  • One frame after May’s end, Miami’s label read 216 instead of 215, because the next move had already started. Read values at the period ends.

Use your own data

  • Labels. The script inserts the bar names into the page as HTML. Use plain, trusted names, or switch to textContent before you accept names from users.
  • Number of bars. The layout fits six bars at ROW = 120. For more bars, lower ROW and the font sizes in CSS. Past six bars, the six COLORS repeat.
  • Pace. STEP sets the seconds per period and HOLD how long the last period stays on screen.
  • Title. Change TITLE, and keep “(sample data)” or a source note in it, so viewers know where the numbers come from.

Turn your data into video with Shotstack

You now have a script that turns a CSV into a bar chart race. It ranks the bars in Python, animates them in one html5 clip and renders the MP4 with the Edit API. Swap in your own CSV, such as monthly sales, signups or votes, and the same script renders a new race.

To turn each row of a CSV into its own video instead, see how to bulk create videos from a CSV.

Create a free Shotstack account to get your API key and render your first bar chart race.

Frequently asked questions (FAQs)

Can I show only the top five bars?

Not with the script as written: every bar stays on screen. To show a top N, fade out each row whose rank is N or more in that period. Add a GSAP autoAlpha tween next to the row’s move, set to 0 or 1 from the rank.

Can I use one fixed scale instead of the leader’s?

Yes. In race(), divide by the largest value in the whole file instead of the period’s leader. Bars then grow over time, and the leader only fills the full width in its best period.

Can I make the chart vertical for Shorts or Reels?

Yes, but the layout needs changing. Set the output and the page to 1080 × 1920 in build_edit() and CSS, then shrink BAR_MAX and move the rows down so they fit the narrower frame.

Keep reading

Start free

  • Unlimited developer sandbox
  • No credit card required