How to Create a Ridgeline Plot in Seaborn

Ridgeline plots, also called joyplots, display multiple density distributions stacked vertically with slight overlap. Each 'ridge' represents a distribution for a specific category, creating a...

Key Insights

  • Seaborn doesn’t have a native ridgeline plot function, but you can build one using FacetGrid with KDE plots and manual y-axis offsetting to create the characteristic overlapping distribution effect
  • The key technique involves iterating through categories, plotting each distribution, and applying a vertical offset that creates the “ridge” appearance while maintaining readability through careful alpha transparency
  • Ridgeline plots excel at showing distribution changes across ordered categories (time, rankings, sequences) but become cluttered with more than 10-12 categories—use violin or box plots for larger datasets

What Are Ridgeline Plots?

Ridgeline plots, also called joyplots, display multiple density distributions stacked vertically with slight overlap. Each “ridge” represents a distribution for a specific category, creating a mountain range effect that makes it easy to compare how distributions shift across categories.

They’re particularly effective for time-series data where you want to show how distributions evolve—think temperature patterns across months, stock price volatility over years, or user engagement metrics across product versions. The overlapping design lets you pack more information into less vertical space while maintaining clarity about individual distributions.

Setup and Dependencies

You’ll need seaborn, matplotlib, pandas, and numpy. Install them if you haven’t already:

pip install seaborn matplotlib pandas numpy

Here’s the basic setup with a sample dataset:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Set style
sns.set_theme(style="white", rc={"axes.facecolor": (0, 0, 0, 0)})

# Generate sample data: test scores across different study groups
np.random.seed(42)
categories = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5']
data = []

for i, category in enumerate(categories):
    # Scores improve over time with increasing mean
    scores = np.random.normal(loc=60 + i*5, scale=10, size=200)
    data.extend([{'Week': category, 'Score': score} for score in scores])

df = pd.DataFrame(data)

This creates a realistic dataset where test scores gradually improve over five weeks, perfect for demonstrating distribution changes.

Building Your First Ridgeline Plot

The core technique uses FacetGrid to create separate plots for each category, then manually offsets them on the y-axis. Here’s the step-by-step approach:

# Initialize the FacetGrid
g = sns.FacetGrid(df, row='Week', hue='Week', aspect=15, height=0.5, palette='viridis')

# Map the densities
g.map(sns.kdeplot, 'Score', bw_adjust=0.5, clip_on=False, fill=True, alpha=0.7, linewidth=1.5)
g.map(sns.kdeplot, 'Score', bw_adjust=0.5, clip_on=False, color='w', lw=2)

# Add horizontal line for each plot
g.map(plt.axhline, y=0, lw=2, clip_on=False)

# Define and use a function to label each facet
def label(x, color, label):
    ax = plt.gca()
    ax.text(0, 0.2, label, fontweight='bold', color=color,
            ha='left', va='center', transform=ax.transAxes)

g.map(label, 'Score')

# Remove axes details
g.set_titles('')
g.set(yticks=[], ylabel='')
g.despine(bottom=True, left=True)

plt.xlabel('Test Score')
plt.tight_layout()
plt.show()

This creates the classic ridgeline effect. The double kdeplot calls create both the filled distribution and a white outline for definition. The aspect parameter controls width-to-height ratio, while height sets the size of each ridge.

Customizing for Visual Impact

Raw ridgeline plots work, but customization makes them publication-ready. Here’s an enhanced version with better aesthetics:

# Create figure with custom size
fig, axes = plt.subplots(figsize=(10, 6))

# Define custom palette
colors = sns.color_palette("rocket", n_colors=len(categories))

# Manual approach for more control
y_offset = 0
spacing = 0.5

for i, category in enumerate(reversed(categories)):
    subset = df[df['Week'] == category]['Score']
    
    # Create KDE
    kde_data = sns.kdeplot(subset, color=colors[i], fill=True, 
                           alpha=0.6, linewidth=2, bw_adjust=0.5)
    
    # Get the line data and offset it
    line = kde_data.get_lines()[-1]
    x_data = line.get_xdata()
    y_data = line.get_ydata()
    
    # Apply vertical offset
    plt.fill_between(x_data, y_offset, y_data + y_offset, 
                     color=colors[i], alpha=0.6)
    plt.plot(x_data, y_data + y_offset, color=colors[i], linewidth=2)
    
    # Add label
    plt.text(45, y_offset + 0.01, category, fontsize=11, 
             fontweight='bold', color=colors[i])
    
    y_offset += spacing
    plt.clf()  # Clear for next iteration

# Recreate with proper offsetting
y_offset = 0
for i, category in enumerate(reversed(categories)):
    subset = df[df['Week'] == category]['Score']
    density = sns.kdeplot(subset, color=colors[i], fill=True,
                         alpha=0.6, linewidth=2, bw_adjust=0.5, ax=axes)
    
    # Manual offset transformation
    for collection in density.collections:
        path = collection.get_paths()[0]
        vertices = path.vertices
        vertices[:, 1] += y_offset
    
    for line in density.lines:
        line.set_ydata(line.get_ydata() + y_offset)
    
    # Label
    axes.text(45, y_offset + 0.005, category, fontsize=11, 
              fontweight='bold', va='bottom')
    
    y_offset += spacing

axes.set_xlim(30, 100)
axes.set_ylim(0, y_offset)
axes.set_xlabel('Test Score', fontsize=12)
axes.set_yticks([])
sns.despine(left=True)
plt.tight_layout()
plt.show()

The manual offset approach gives you precise control over spacing and allows for custom labeling positions.

Advanced Techniques

Add reference lines to highlight specific values like means or thresholds:

# Create ridgeline with reference lines
g = sns.FacetGrid(df, row='Week', hue='Week', aspect=15, height=0.5, 
                  palette='mako', xlim=(30, 100))

g.map(sns.kdeplot, 'Score', bw_adjust=0.5, clip_on=False, 
      fill=True, alpha=0.7, linewidth=1.5)
g.map(sns.kdeplot, 'Score', bw_adjust=0.5, clip_on=False, 
      color='w', lw=2)

# Add mean lines
for ax, category in zip(g.axes.flat, categories):
    mean_score = df[df['Week'] == category]['Score'].mean()
    ax.axvline(mean_score, color='red', linestyle='--', 
               linewidth=2, alpha=0.8, zorder=10)

g.map(plt.axhline, y=0, lw=2, clip_on=False)

# Custom labeling function
def label_with_stats(x, color, label):
    ax = plt.gca()
    mean = df[df['Week'] == label]['Score'].mean()
    ax.text(0, 0.2, f'{label}\n(μ={mean:.1f})', 
            fontweight='bold', color=color,
            ha='left', va='center', transform=ax.transAxes, fontsize=10)

g.map(label_with_stats, 'Score')

g.set_titles('')
g.set(yticks=[], ylabel='')
g.despine(bottom=True, left=True)
plt.xlabel('Test Score', fontsize=12)
plt.tight_layout()
plt.show()

This adds vertical reference lines at each distribution’s mean and includes summary statistics in the labels.

Real-World Application: Temperature Patterns

Here’s a complete example using realistic temperature data:

# Generate realistic temperature data
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 
          'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
temp_data = []

# Simulate temperature patterns
base_temps = [32, 35, 45, 55, 65, 75, 80, 78, 70, 58, 45, 35]
for month, base in zip(months, base_temps):
    temps = np.random.normal(loc=base, scale=8, size=300)
    temp_data.extend([{'Month': month, 'Temperature': temp} for temp in temps])

temp_df = pd.DataFrame(temp_data)

# Create polished ridgeline plot
g = sns.FacetGrid(temp_df, row='Month', hue='Month', aspect=12, height=0.6,
                  palette=sns.color_palette("coolwarm", n_colors=12),
                  row_order=months)

g.map(sns.kdeplot, 'Temperature', bw_adjust=0.8, clip_on=False,
      fill=True, alpha=0.7, linewidth=2)
g.map(sns.kdeplot, 'Temperature', bw_adjust=0.8, clip_on=False,
      color='w', lw=2.5)
g.map(plt.axhline, y=0, lw=3, clip_on=False)

# Add month labels
def label_month(x, color, label):
    ax = plt.gca()
    ax.text(-0.05, 0.15, label, fontweight='bold', color=color,
            ha='right', va='center', transform=ax.transAxes, fontsize=11)

g.map(label_month, 'Temperature')

# Clean up
g.fig.subplots_adjust(hspace=-0.4)  # Increase overlap
g.set_titles('')
g.set(yticks=[], ylabel='')
g.despine(bottom=True, left=True)
plt.xlabel('Temperature (°F)', fontsize=13, fontweight='bold')
plt.xlim(10, 100)
plt.tight_layout()
plt.show()

This visualization clearly shows how temperature distributions shift throughout the year, with wider distributions in transitional months and narrower ones in summer and winter peaks.

Best Practices and When to Use Ridgeline Plots

Use ridgeline plots when:

  • You have 3-12 ordered categories (time periods, rankings, stages)
  • Distribution shape matters as much as summary statistics
  • You want to show smooth transitions between categories
  • Space efficiency is important

Avoid them when:

  • You have more than 12-15 categories (they become illegible)
  • Categories aren’t naturally ordered
  • Precise value comparisons are critical (use box plots instead)
  • Your audience isn’t familiar with density plots

Common pitfalls:

  • Too much overlap makes individual distributions hard to read—adjust hspace carefully
  • Insufficient bandwidth adjustment (bw_adjust) can create noisy distributions
  • Poor color choices reduce distinguishability—use sequential palettes for ordered data
  • Missing axis labels confuse readers unfamiliar with the format

Ridgeline plots are powerful for storytelling with distributions. They immediately communicate patterns that would require multiple separate plots otherwise. Use them when the narrative is about evolution, progression, or comparison across a natural sequence.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.