6  Quarto

In Your First R Project, you worked with a .qmd file—running code chunks interactively, viewing results in the plots pane, and rendering the document to HTML. This chapter explains Quarto’s syntax and features in depth so you can write your own analysis documents from scratch.

Quarto is a scientific publishing system that lets you combine code, results, and narrative text in a single document. You write analysis scripts as .qmd files—plain text with embedded R or Python code—and Quarto renders them into polished HTML reports with your outputs automatically included.

In the Musser Lab, Quarto documents are the standard format for data analysis because they solve a fundamental problem: keeping your code, your results, and your explanation of those results in sync. When you render a .qmd file, Quarto executes the code fresh and weaves the outputs into the document, so the report always reflects the actual analysis.

6.1 Why Quarto?

6.1.1 Compared to Plain Scripts

A plain .R or .py script is just code. It can produce outputs, but there’s no built-in way to document what the code does, why you made certain choices, or what the results mean. When you share a script, collaborators see the code but not your interpretation. Comments help, but they’re limited—you can’t embed figures, formatted tables, or section headings in a comment.

With Quarto:

  • Code and narrative live together — explain what you’re doing as you do it
  • Results are embedded — figures and tables appear in the document
  • Output is shareable — render to HTML that anyone can open in a browser

6.1.2 Compared to Jupyter Notebooks

If you’ve used Jupyter notebooks (.ipynb), you might wonder why we use Quarto instead. Jupyter is a great tool for interactive exploration, but .qmd files have several advantages for reproducible analysis:

Plain text, not JSON. Jupyter notebooks are stored as JSON with embedded output cells, making them difficult to version control with Git. A small code change can produce a massive diff because the output cells change too. Quarto documents are plain text—diffs show exactly what you changed, and merge conflicts are easy to resolve.

Fresh-session rendering. When you run cells in a Jupyter notebook, results depend on the order you executed them and what’s lingering in memory. It’s easy to have a notebook that “works” only because you ran cell 5 before cell 3 during development. Quarto renders in a fresh session every time, catching hidden dependencies that Jupyter lets slip through.

Positron integration. In Positron, you get the best of both worlds—interactive execution with Cmd+Enter (like Jupyter) plus a persistent console, Variables pane, and Data Viewer alongside your document. You don’t lose the interactive workflow; you gain reproducibility on top of it.

One format, both languages. Quarto treats R and Python as equal citizens. The same .qmd format, chunk options, and rendering pipeline work for both. No need to learn separate tools for each language.

If you’re coming from Jupyter, the transition is straightforward: your code goes in fenced chunks instead of cells, your markdown goes between chunks instead of in markdown cells, and you render the whole document instead of running cells individually. The interactive development experience in Positron feels very similar to Jupyter—you just get reproducibility as a bonus.

6.1.3 Compared to R Markdown

If you’ve used R Markdown (.Rmd), Quarto will feel familiar. The core idea—mixing code, results, and narrative—is the same. Quarto is R Markdown’s successor, developed by Posit (the company behind RStudio) as a unified system that works equally well with R, Python, Julia, and other languages.

R Markdown Quarto
File extension .Rmd .qmd
Language support R-centric (Python possible but awkward) Multi-language by design
Chunk options {r, echo=FALSE} #| echo: false (YAML-style)
Output formats Documents, some websites Documents, websites, books, slides, dashboards

You don’t need to memorize the differences. If you know R Markdown, Quarto works similarly. If you’re new to both, just learn Quarto—it’s the modern standard.

6.1.4 What Else Can Quarto Do?

While we primarily use Quarto for analysis scripts, it’s a general-purpose publishing system. This book was built with Quarto. You can also create:

  • Websites — project documentation, lab websites
  • Presentations — slides rendered from code (RevealJS format)
  • Dashboards — interactive displays of data

See the Quarto documentation if you want to explore these formats.

6.2 The Interactive Development Workflow

Before diving into syntax, understand how you’ll actually work with Quarto documents. There are two modes, and you’ll spend most of your time in the first one.

Interactive development is where the real work happens. You write a chunk of code in your .qmd file, run it with Cmd+Enter (macOS) or Ctrl+Enter (Windows), inspect the output in the Console or Data Viewer, tweak it, run again. Objects you create persist in your R or Python session, so you can build on previous chunks. This feels just like working in a Jupyter notebook or an R console—the .qmd file is your scratch pad, and Positron is your workbench.

[TODO: screenshot of Positron with .qmd open, console showing output, Data Viewer with dataframe]

Rendering is for validation and sharing. When the analysis is complete—or when you want to check that everything works end-to-end—you render the document:

quarto render my_analysis.qmd

Rendering executes every code chunk in a fresh session (no leftover objects from interactive work) and produces a standalone HTML file. This catches errors you might miss interactively, like relying on an object you created manually but forgot to include in the script.

A typical session looks like this: you open your project in Positron, create or open a .qmd file, and start writing chunks—setup first (libraries, paths), then data loading, then analysis. You run each chunk interactively, checking results as you go. When the analysis feels complete, you render the whole document. If rendering fails (usually a missing object or package), you fix it and render again. Once it succeeds, you commit the .qmd file.

The key insight: interactive mode is for development, rendering is for validation. Always render before committing or sending a report to someone.

6.3 Writing a Quarto Document

A Quarto document has two parts: a YAML header (metadata and rendering options, between --- markers at the top) and a body (Markdown text interspersed with code chunks).

6.3.1 Creating a New Document

To start a new Quarto document in Positron, go to the File Explorer in the left sidebar, right-click inside your scripts/ folder, and select New File. Name it with a .qmd extension (like 01_analysis.qmd). Positron recognizes the extension and gives you Quarto syntax highlighting and the Render button in the toolbar. Start by pasting in the YAML header and setup chunk from the templates below, then begin adding your own sections and code chunks.

6.3.2 Document Structure

Here’s a minimal example:

---
title: "My Analysis"
format: html
---

## Introduction

This analysis examines the relationship between X and Y.

```{r}
library(tidyverse)
data <- read_csv("data/input.csv")
glimpse(data)
```

## Results

```{r}
ggplot(data, aes(x = x, y = y)) +
  geom_point()
```

When rendered, this produces an HTML file with the title, your text, and the code outputs (the glimpse() output and the plot) embedded. For Python, the only difference is the chunk fence—use ```{python} instead of ```{r}.

6.3.3 The YAML Header

The YAML header controls document metadata and rendering behavior. Here’s what a typical lab analysis header looks like:

---
title: "Phosphoproteomics Volcano Plots"
subtitle: "Tryptamine treatment time course"
author: "Your Name"
date: today
status: development
format:
  html:
    toc: true
    toc-depth: 2
    number-sections: true
    code-overflow: wrap
    code-fold: false
    code-tools: true
    highlight-style: github
    theme: cosmo
    fontsize: 1rem
    linestretch: 1.5
    self-contained: true
execute:
  echo: true
  message: false
  warning: false
  cache: false
---
---
title: "Phosphoproteomics Volcano Plots"
subtitle: "Tryptamine treatment time course"
author: "Your Name"
date: today
status: development
jupyter: python3
format:
  html:
    toc: true
    toc-depth: 2
    number-sections: true
    code-overflow: wrap
    code-fold: false
    code-tools: true
    highlight-style: github
    theme: cosmo
    fontsize: 1rem
    linestretch: 1.5
    self-contained: true
execute:
  echo: true
  warning: false
  cache: false
---

The two headers are nearly identical. Python adds jupyter: python3 (which tells Quarto how to run the Python code) and drops message: false (which is R-specific—R packages print startup messages, Python packages generally don’t).

Here’s what the key options do:

  • status tracks the script’s lifecycle stage (development, finalized, deprecated)—see Project Organization
  • toc: true adds a table of contents, making long analyses navigable
  • self-contained: true bundles everything into a single HTML file you can email or share
  • code-tools: true adds a button to show/hide all code at once
  • echo: true shows your code in the output—important for analysis scripts where the code is part of the documentation
  • message: false / warning: false keeps rendered output clean by suppressing package messages and warnings

For the full list of YAML options, see the Quick Reference tables at the end of this chapter. For a copy-paste template you can use to start new scripts, see Appendix C.

WarningClaude Code

Claude Code can generate a complete YAML header and setup chunk tailored to your analysis.

I’m starting a new R analysis script for differential expression using DESeq2. Set up a QMD with the standard lab header and a setup chunk that loads DESeq2, tidyverse, and here.

Claude will create a .qmd file with the full YAML header, setup chunk with library loading, output directory creation, and provenance tracking—all following the lab conventions described in this chapter.

6.3.4 The Setup Chunk

Every analysis script should start with a setup chunk that loads packages, defines paths, sets options, and captures the git commit hash for provenance tracking.

#| label: setup
#| include: false

# ---- Libraries ----
suppressPackageStartupMessages({
  library(tidyverse)
  library(here)
})

# ---- Paths ----
dir_data <- here::here("data")
dir_out <- here::here("outs", "01_script_name")
dir.create(dir_out, recursive = TRUE, showWarnings = FALSE)

# ---- Options ----
options(stringsAsFactors = FALSE)
set.seed(42)

# ---- Provenance ----
git_hash <- system("git rev-parse --short HEAD", intern = TRUE)
cat("Rendered from commit:", git_hash, "\n")
#| label: setup

# Standard library modules (built into Python)
import subprocess, sys, random
from pathlib import Path
from datetime import datetime

# Third-party packages (installed via conda)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# ---- Project Root ----
# Find the project root by asking Git where the repository starts.
# This lets us build reliable file paths regardless of where the script lives.
PROJECT_ROOT = Path(subprocess.check_output(
    ["git", "rev-parse", "--show-toplevel"]
).decode().strip())
# Add the project's python/ folder so we can import our own helper modules
sys.path.insert(0, str(PROJECT_ROOT / "python"))

# ---- Options ----
random.seed(42)
np.random.seed(42)
pd.set_option("display.max_columns", None)
sns.set_theme(style="whitegrid")

# ---- Paths ----
out_dir = PROJECT_ROOT / "outs/01_script_name"
out_dir.mkdir(parents=True, exist_ok=True)

# ---- Provenance ----
git_hash = subprocess.check_output(
    ["git", "rev-parse", "--short", "HEAD"]
).decode().strip()
print(f"Rendered from commit: {git_hash}")
NoteThese Templates Require Git

The provenance section uses git rev-parse to capture the current commit hash, and the Python version uses it to find the project root. These commands only work if your project is a Git repository. If you haven’t set up Git yet (covered in the Git & GitHub chapter), you can comment out the provenance lines for now and add them back later when your project is under version control.

Both versions follow the same pattern: libraries, paths, options, provenance. The key differences:

  • #| include: false (R only) — hides the setup chunk from the rendered output. Python setup chunks typically stay visible because the import list serves as documentation.
  • suppressPackageStartupMessages() (R) — prevents the noisy startup messages that R packages print when loaded.
  • here::here() (R) vs PROJECT_ROOT (Python) — both build file paths relative to the project root, not the script location. The R version uses the here package; the Python version finds the root via git rev-parse. See Working Directory and Paths.
  • set.seed(42) — makes random operations reproducible. Python needs two seeds (random.seed for stdlib, np.random.seed for NumPy).

6.3.5 Code Chunks

Code chunks are fenced with triple backticks and a language identifier:

```{r}
# R code here
```

```{python}
# Python code here
```

Use the #| syntax to set options for individual chunks:

#| label: load-data
#| message: false

data <- read_csv(here::here("data", "input.csv"))

The label option names the chunk. This is useful for debugging (error messages reference the chunk label), cross-references (linking to figures), and caching.

Here are the chunk patterns you’ll use most often:

Hidden setup — runs but doesn’t appear in output:

#| label: setup
#| include: false

library(tidyverse)

Visible code with clean output — shows code, hides messages:

#| label: load-data
#| message: false
#| warning: false

data <- read_csv("data.csv")

Figure with caption — for publication-ready figures:

#| label: fig-volcano
#| fig-cap: "Volcano plot showing differential expression"
#| fig-width: 6
#| fig-height: 4

ggplot(results, aes(x = logFC, y = -log10(pvalue))) +
  geom_point()

Code shown but not run — for demonstrating syntax:

#| label: example-syntax
#| eval: false

# This code is displayed but not executed
hypothetical_function()

For the full table of chunk options, see the Quick Reference at the end of this chapter.

6.3.6 Documenting Inputs and Outputs

After the setup chunk, add an explicit inputs section that loads all dependencies. This makes it immediately clear what the script needs to run:

#| label: inputs

# --- Inputs (from other scripts) ---
results <- readRDS(here("outs/03_differential/limma_results.rds"))

# --- Inputs (external data) ---
metadata <- read_csv(here("data/sample_metadata.csv"))
#| label: inputs

# --- Inputs (from other scripts) ---
results = pd.read_parquet(PROJECT_ROOT / "outs/03_differential/deseq_results.parquet")

# --- Inputs (external data) ---
metadata = pd.read_csv(PROJECT_ROOT / "data/sample_metadata.csv")

Separating inputs from analysis code makes dependencies self-documenting—reading the top of any script shows exactly what it depends on. You can also document inputs and outputs in the introduction text:

# Volcano Plots

This script generates volcano plots for the phosphoproteomics time course.

**Inputs:**

- `outs/03_differential/limma_results.rds`
- `data/sample_metadata.csv`

**Outputs:**

- `outs/04_volcano_plots/volcano_*.pdf`
- `outs/04_volcano_plots/significant_hits.csv`

See the Project Organization chapter for how this fits into the larger project structure.

6.4 Saving Outputs

Don’t rely on the Plots pane or copy-paste. Save figures and tables to files explicitly:

#| label: fig-volcano
#| fig-cap: "Volcano plot showing differential phosphorylation"

p <- ggplot(results, aes(x = logFC, y = -log10(adj.P.Val))) +
  geom_point() +
  theme_minimal()

# Display in rendered document
print(p)

# Save to output folder
ggsave(
  file.path(dir_out, "volcano_plot.pdf"),
  plot = p,
  width = 6,
  height = 4
)

For tables:

write_csv(significant_hits, file.path(dir_out, "significant_hits.csv"))
#| label: fig-volcano
#| fig-cap: "Volcano plot showing differential phosphorylation"

fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(results["logFC"], -np.log10(results["adj_pval"]))
ax.set_xlabel("log2 Fold Change")
ax.set_ylabel("-log10 Adjusted P-value")
plt.tight_layout()

# Save to output folder AND display inline
fig.savefig(out_dir / "volcano_plot.pdf", dpi=300, bbox_inches="tight")
fig.savefig(out_dir / "volcano_plot.png", dpi=300, bbox_inches="tight")
plt.show()

For tables:

significant_hits.to_csv(out_dir / "significant_hits.csv", index=False)

This approach embeds the figure in the rendered HTML, saves a high-quality copy for publication or further use, and makes the output traceable (the file exists in outs/).

6.5 Working Directory and Paths

When Quarto renders a .qmd, the working directory is the folder containing the .qmd file—not the project root. This can cause path confusion.

project/
├── scripts/
│   └── 01_analysis.qmd    ← Working directory during render
├── data/
│   └── input.csv
└── outs/

From scripts/01_analysis.qmd, a relative path to the data would be ../data/input.csv. But this breaks if you move the script or run it from a different location.

The here package finds the project root (by looking for .git, .Rproj, etc.) and builds paths from there:

# Always works, regardless of working directory
data <- read_csv(here::here("data", "input.csv"))

# Instead of fragile relative paths
data <- read_csv("../data/input.csv")  # Don't do this

Python doesn’t have a direct equivalent of here, so the setup chunk finds the project root via Git:

PROJECT_ROOT = Path(subprocess.check_output(
    ["git", "rev-parse", "--show-toplevel"]
).decode().strip())

# Always works, regardless of working directory
data = pd.read_csv(PROJECT_ROOT / "data/input.csv")

# Instead of fragile relative paths
data = pd.read_csv("../data/input.csv")  # Don't do this

Use project-root-relative paths for all file operations in your scripts. It makes your code portable and reproducible.

6.6 Rendering

6.6.1 From the Terminal

quarto render scripts/01_analysis.qmd

Python QMD files require the conda environment to be active when you render. If you haven’t set up conda yet, the Conda chapter walks through installation and environment creation — come back to this section after that.

source ~/miniconda3/etc/profile.d/conda.sh && conda activate my-project && quarto render scripts/02_plots.qmd

If quarto render fails with ModuleNotFoundError, check that your conda environment is active. This is the number one Python QMD gotcha.

To render all .qmd files in a directory:

quarto render scripts/

6.6.2 From Positron

Click the Render button in the editor toolbar, or use Cmd+Shift+K (macOS) / Ctrl+Shift+K (Windows).

6.6.3 Preview Mode

For live feedback during development, use preview mode:

quarto preview scripts/01_analysis.qmd

This opens a browser window that automatically refreshes when you save changes. Useful for tuning formatting, but remember that preview still renders in a fresh session—it’s not the same as interactive execution.

6.7 Best Practices

6.7.1 Render Before Committing

Always render the full document before committing to Git. This catches missing packages (installed interactively but not in your lockfile), missing objects (created interactively but not in the script), and order dependencies (chunks that depend on earlier chunks you modified).

6.7.2 Self-Contained Scripts

Every .qmd should run successfully from a fresh R or Python session. Don’t assume objects exist from previous interactive work. If render fails, your script isn’t self-contained.

6.7.3 Clear Section Structure

Organize your script with clear headings:

# Introduction
# Setup
# Load Data
# Analysis
# Results
# Summary

This makes the script navigable (via Positron’s Outline panel) and the rendered document readable.

6.7.4 Validation Chunks

For complex analyses, add validation chunks that verify data integrity:

#| label: validate-data
#| include: false

# Check required columns exist
required_cols <- c("sample_id", "condition", "value")
missing_cols <- setdiff(required_cols, names(data))
if (length(missing_cols) > 0) {
  stop("Missing required columns: ", paste(missing_cols, collapse = ", "))
}

stopifnot("No data loaded" = nrow(data) > 0)

These chunks catch problems early and provide clear error messages.

6.7.5 Provenance Chunk

End every script with a chunk that writes BUILD_INFO.txt and prints session information. This records which script, commit, and timestamp produced the outputs—answering “when was this last regenerated?”

#| label: build-info

writeLines(
  c(
    paste("script:", "01_script_name.qmd"),
    paste("commit:", git_hash),
    paste("date:", format(Sys.time(), "%Y-%m-%d %H:%M:%S"))
  ),
  file.path(dir_out, "BUILD_INFO.txt")
)

sessionInfo()
#| label: build-info

(out_dir / "BUILD_INFO.txt").write_text(
    f"script: 01_script_name.qmd\n"
    f"commit: {git_hash}\n"
    f"date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
)

import session_info
session_info.show()

6.8 Common Issues

WarningClaude Code

Claude Code can debug render failures, especially the common case where code works interactively but fails during rendering.

When I run quarto render analysis.qmd, I get this error: [paste error]. The code runs fine interactively in the console. What’s going wrong?

Claude will trace the error to its cause—typically a missing object that existed in your interactive session but isn’t created in the script, or a path that resolves differently during rendering.

6.8.1 “Object not found” During Render

Code that works interactively may fail during render if you rely on objects not created in the script. You created an object manually in the console, then used it in a chunk without creating it in the script. Ensure all objects are created within the .qmd and render frequently to catch this early.

6.8.2 Figures Not Appearing

If a plot doesn’t show up in the rendered output, add an explicit print():

p <- ggplot(data, aes(x, y)) + geom_point()
print(p)  # Required inside loops or complex chunks

The explicit print() is needed when the plot is the result of an assignment or is inside a loop/function.

6.8.3 Package Messages Cluttering Output

Set message: false and warning: false in the YAML execute: block to suppress them globally, or per-chunk for specific chunks.

6.8.4 Output File Is Huge

If your HTML is very large:

  • Embedded images: Use self-contained: false to keep images in a separate _files folder
  • High resolution: Add fig-dpi: 150 to reduce figure resolution
  • SVG format: Use fig-format: png instead of SVG for complex plots

6.8.5 Chunk Takes Forever

For long-running chunks during development, consider #| cache: true. This caches the chunk’s results—subsequent renders skip execution if the code hasn’t changed. Use sparingly, as caching can cause subtle bugs if dependencies change.

6.9 Quick Reference

6.9.1 YAML Options

6.9.1.1 Document Metadata

Option Purpose Example
title Document title (appears at top) "My Analysis"
subtitle Secondary title "Brief description"
author Your name "Jane Doe"
date Date; use today for automatic today
status Script lifecycle stage development
jupyter Jupyter kernel (Python only) python3

6.9.1.2 Format Options (under format: html:)

Option Purpose Recommended
toc Include table of contents true
toc-depth How many heading levels in TOC 2
number-sections Number your headings true
code-overflow How to handle long code lines wrap
code-fold Collapse code blocks by default false
code-tools Add show/hide all code button true
highlight-style Syntax highlighting theme github
theme Bootstrap theme for HTML cosmo
fontsize Base font size 1rem
linestretch Line spacing multiplier 1.5
self-contained Single HTML file true

6.9.1.3 Execute Options (under execute:)

Option Purpose Recommended
echo Show code in output true
message Show package messages (R only) false
warning Show warnings false
cache Cache chunk results false

6.9.2 Chunk Options

Option Purpose Values
label Chunk name Any string
echo Show code true / false
eval Run code true / false
include Include in output true / false
message Show messages true / false
warning Show warnings true / false
error Continue on error true / false
cache Cache results true / false
fig-cap Figure caption String
fig-width Width in inches Number
fig-height Height in inches Number
fig-dpi Resolution Number (default 72)
fig-format Output format png / svg / pdf
tbl-cap Table caption String

6.9.3 R vs Python Comparison

Convention R Python
Project root here::here() PROJECT_ROOT (from git)
Read CSV read_csv(here("data/file.csv")) pd.read_csv(PROJECT_ROOT / "data/file.csv")
Read TSV read_tsv(here("data/file.tsv")) pd.read_csv(PROJECT_ROOT / "data/file.tsv", sep="\t")
Read Parquet arrow::read_parquet(here(...)) pd.read_parquet(PROJECT_ROOT / ...)
Save figure ggsave(file.path(dir_out, "fig.pdf")) fig.savefig(out_dir / "fig.pdf")
Random seed set.seed(42) random.seed(42) + np.random.seed(42)
Session info sessionInfo() session_info.show()
Chunk label {r label-name} or #| label: #| label: only
Helper loading source(here("R/helpers.R")) sys.path.insert(0, str(PROJECT_ROOT / "python"))

6.9.4 Quarto Commands

Command Purpose
quarto render file.qmd Render to HTML
quarto render file.qmd --to pdf Render to PDF
quarto preview file.qmd Live preview in browser
quarto render directory/ Render all .qmd in directory
quarto check Verify Quarto installation

6.9.5 Keyboard Shortcuts (Positron)

Action macOS Windows
Run line/selection Cmd+Enter Ctrl+Enter
Run chunk Cmd+Shift+Enter Ctrl+Shift+Enter
Render document Cmd+Shift+K Ctrl+Shift+K
Insert R chunk Cmd+Option+I Ctrl+Alt+I

For complete, copy-paste-ready templates (YAML headers, setup chunks, provenance chunks for both R and Python), see Appendix C.

6.10 Python-Specific Notes

This section collects the Python-specific details that don’t fit neatly into the tabsets above. If you haven’t set up conda yet, read the Conda chapter first — these notes will make more sense after that.

Conda must be active for rendering. This is the number one Python QMD gotcha. Unlike R (which finds packages via renv automatically), Python QMD files require the conda environment to be active when you render. If quarto render fails with ModuleNotFoundError, activate your environment first.

Install ipykernel. Quarto uses Jupyter kernels to execute Python code. Your conda environment needs ipykernel installed (conda install ipykernel) or rendering will fail.

Install session-info. For the provenance chunk to work, install session-info in your conda environment: pip install session-info. Note the dash in the install name vs. the underscore in the import (import session_info) — this is a common Python convention where package install names use dashes but module names use underscores.

Don’t mix languages. Do not mix R and Python chunks in a single .qmd. Each script uses one language. Scripts communicate through files in outs/, not shared memory. If an R script produces data that a Python script needs, save it as Parquet (see Project Organization).

Selecting the interpreter. Ensure Positron is using the correct conda environment: open the Command Palette (Cmd/Ctrl+Shift+P), type “Python: Select Interpreter”, and choose your project’s environment.