Migrating from Pelican to Quarto: A Data Scientist’s Case For The Switch
I built kivanpolimis.com on Pelican in 2016. That was the right call then: Python-based, simple to configure, fast to deploy, Markdown-native. I’ve been writing posts on it for a decade.
I’m moving to Quarto. This post explains why, what the migration looks like technically, and what I think it means for data scientists who maintain personal sites.
What Pelican Does Well (and Where It Stops)
Pelican is a static site generator. You write Markdown, it renders HTML. The workflow for a data science blog is: run your analysis in a notebook or script, export a chart as a PNG, embed the PNG in a Markdown post, deploy. Clean enough.
The problem is that the chart is static. If the underlying data changes, or if you want to show the code that generated the chart, or if you want an interactive visualization, you’re either embedding screenshots or maintaining a separate JavaScript layer that has no connection to your analysis code.
More fundamentally: a static image of a chart is not reproducible. It’s a claim that some code produced some output at some point. Quarto makes the code the source of truth.
What Quarto Actually Is
Quarto is a scientific publishing system. It takes .qmd files (Quarto Markdown) and renders them to HTML, PDF, Word, or slides. Code blocks in .qmd files execute during the render (in Python, R, Julia, or Observable) and their output (tables, plots, printed results) is embedded directly.
A Quarto blog post that includes a regression table generates that table at build time, from the actual model, every time the site builds. The number in the text and the number in the table are the same number because they’re both computed from the same object.
This is not a new idea. R Markdown did this first, and Jupyter notebooks have always supported it. What Quarto adds is:
- Multi-language support in a single document (Python and R in the same
.qmd, sharing a knitr or Jupyter kernel) - Scientific publishing features native to the format: BibTeX citations, LaTeX math, cross-references, callout blocks, theorem environments
- A first-class website publishing system via
quarto publish - Freeze: a mechanism to cache rendered outputs so you don’t re-execute expensive analyses on every build
The Architecture Comparison
Pelican:
content/
├── posts/
│ ├── nba-shot-charts.md ← static Markdown
│ └── covid-mortality.md ← static Markdown
├── images/
│ └── shot_chart_2022.png ← pre-generated image
└── pelicanconf.py ← Python config
Quarto:
posts/
├── nba-shot-charts/
│ ├── index.qmd ← code + prose, renders together
│ └── _freeze/ ← cached execution results
├── covid-mortality/
│ └── index.qmd
└── _quarto.yml ← site config
The key structural difference: in Quarto, each post is a directory, not a file. The directory contains the prose, the code, any data files, and the freeze cache. The post is a self-contained unit.
A Concrete Example: Reproducing the NBA Shot Chart Post
My NBA Shot Charts post was written in 2022 with static images. Here’s what the Quarto version looks like:
Old approach (Pelican):
# NBA Shot Charts — Part 1
I generated shot charts for every player this season.
Here's the chart for Russell Westbrook:
New approach (Quarto):
---
title: "NBA Shot Charts — Part 1"
date: "2026-05-17"
execute:
echo: true
warning: false
---
# NBA Shot Charts — Part 1
I'm pulling shot data from the NBA Stats API and generating
charts using `matplotlib` and `BasketballMap`.
```python
from nba_api.stats.endpoints import shotchartdetail
import matplotlib.pyplot as plt
# Pull Westbrook's shots from the 2021-22 season
shot_data = shotchartdetail.ShotChartDetail(
team_id=0,
player_id=1630566,
season_nullable="2021-22",
season_type_all_star="Regular Season",
context_measure_simple="FGA",
).get_data_frames()[0]
# Draw the court and plot
fig, ax = draw_court(ax=None, outer_lines=False)
ax.scatter(shot_data["LOC_X"], shot_data["LOC_Y"],
c=shot_data["SHOT_MADE_FLAG"].map({1: "green", 0: "red"}),
alpha=0.5, s=30)
ax.set_xlim(-300, 300)
ax.set_ylim(-100, 500)
plt.title("Russell Westbrook Shot Chart — 2021-22")
plt.show()
```
The chart above is generated fresh each time the site builds.
If I update the data pull or the styling, the post updates automatically.The code is visible (because echo: true), it runs during build, and the chart is generated from the actual data. Someone reading the post can copy that code block and reproduce the chart themselves.
The Freeze Mechanism
Quarto’s _freeze directory is what makes this practical. Executing an API call and rendering a chart on every site build would be slow and fragile. Freeze caches the execution results:
# _quarto.yml
execute:
freeze: auto # re-execute only when the source file changesWith freeze: auto, a post’s code only re-executes when you edit the .qmd file. The cache stores the rendered outputs, so quarto render on an unchanged post is instant. The NBA API doesn’t get hit on every git push.
You can force re-execution when you want fresh data:
quarto render posts/nba-shot-charts/ --executeThe Migration Process
For existing Pelican posts, the migration is mostly mechanical:
1. Convert Markdown to Quarto Markdown
Pelican metadata headers look like:
Title: NBA Shot Charts — Part 1
Date: 2022-02-27
Category: Sports
Tags: nba, shot-charts, python
Quarto uses YAML front matter:
---
title: "NBA Shot Charts — Part 1"
date: "2022-02-27"
categories: [Sports, NBA, Python]
---A short Python script handles the conversion for all existing posts.
2. Move static images into post directories
# Old structure
mkdir -p posts/nba-shot-charts-part-1/
mv content/posts/nba-shot-charts.md posts/nba-shot-charts-part-1/index.qmd
mv content/images/shot_chart_*.png posts/nba-shot-charts-part-1/3. Update image references
Pelican uses absolute paths from the site root. Quarto uses relative paths from the post directory:
# Old (Pelican)

# New (Quarto)
4. Convert interactive posts to .qmd with code execution
This is the real work and where I’m spending most of the migration time. The COVID mortality series, the NBA MVP comparisons, the shot charts: these all have code that generated the original analysis. Turning them into executing .qmd posts requires verifying the code still runs against current package versions.
Publishing
# One-time setup
quarto publish gh-pages
# Subsequent deploys
quarto render && quarto publish gh-pagesQuarto renders to _site/ and pushes to the gh-pages branch. GitHub Pages serves the result. The workflow is the same as Pelican’s; only the render step changes.
Why This Matters for the “Rigor at Scale” Brand
The websites that shaped my sense of what a data scientist’s personal site should look like (Eugene Yan’s, Vicki Boykis’s, Chip Huyen’s) are notable for being genuinely technical. Not just descriptions of technical work, but the work itself, documented with enough specificity that you could reproduce it.
Quarto makes that easier because the code and the prose live in the same file. The analysis doesn’t exist separately from the writing about it. Every post is its own reproducible artifact.
That’s the brand case. The practical case is simpler: I’m tired of updating PNG files manually when I fix a bug in a chart. Quarto fixes that permanently.
Current Status
The existing Pelican site stays live during the migration. I’m converting posts one category at a time, starting with Tutorials (most code-heavy) and working toward the How-to and Review posts. The target is a fully Quarto-powered site by the end of Q3 2026.
The Pelican source is archived. The Quarto source is at kpolimis.github.io. PRs welcome if you find something that didn’t migrate cleanly.