Waterfall Chart

SaaS ARR Bridge Analysis

Dark-themed waterfall chart showing Annual Recurring Revenue (ARR) changes from new business, expansion, and churn.

Output
SaaS ARR Bridge Analysis
Python
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Patch

# ARR bridge (in millions)
categories = ['Beginning\nARR', 'New\nLogos', 'Expansion', 'Reactivation', 
              'Contraction', 'Churn', 'Ending\nARR']
values = [0, 18.5, 12.2, 2.8, -4.5, -8.2, 0]

# Calculate running total
initial = 85.0
running_total = initial
bottoms = []
heights = []
colors = []

for i, (cat, val) in enumerate(zip(categories, values)):
    if 'Beginning' in cat:
        bottoms.append(0)
        heights.append(initial)
        colors.append('#27D3F5')
    elif 'Ending' in cat:
        bottoms.append(0)
        heights.append(running_total)
        colors.append('#6CF527')
    elif val > 0:
        bottoms.append(running_total)
        heights.append(val)
        colors.append('#27F5B0')
        running_total += val
    else:
        bottoms.append(running_total + val)
        heights.append(abs(val))
        colors.append('#F5276C')
        running_total += val

# Create figure
fig, ax = plt.subplots(figsize=(12, 8), facecolor='#0a0a0f')
ax.set_facecolor('#0a0a0f')

x = np.arange(len(categories))
bars = ax.bar(x, heights, bottom=bottoms, color=colors, width=0.6, edgecolor='#1e293b', linewidth=1)

# Add value labels
for i, (bar, val, bot, height) in enumerate(zip(bars, values, bottoms, heights)):
    y_pos = bot + height / 2
    if 'Beginning' in categories[i] or 'Ending' in categories[i]:
        label = f"${height:.1f}M"
        ax.text(bar.get_x() + bar.get_width()/2, y_pos, label, 
                ha='center', va='center', fontsize=11, fontweight='bold', color='#0a0a0f')
    else:
        label = f"+${val:.1f}M" if val > 0 else f"-${abs(val):.1f}M"
        ax.text(bar.get_x() + bar.get_width()/2, y_pos, label, 
                ha='center', va='center', fontsize=10, fontweight='bold', color='white')

# Connect bars
for i in range(len(x) - 1):
    if i == 0:
        y = initial
    else:
        y = bottoms[i] + heights[i]
    ax.plot([x[i] + 0.35, x[i+1] - 0.35], [y, y], 
            color='#475569', linestyle='--', linewidth=1.5, alpha=0.7)

# Styling
ax.set_xlim(-0.6, len(categories) - 0.4)
ax.set_ylim(0, max(bottoms[i] + heights[i] for i in range(len(heights))) * 1.1)
ax.set_xticks(x)
ax.set_xticklabels(categories, fontsize=10, color='#e2e8f0')
ax.set_ylabel('ARR ($ Millions)', fontsize=12, color='#e2e8f0', fontweight='500')
ax.set_title('SaaS Annual Recurring Revenue Bridge', fontsize=16, color='white', fontweight='bold', pad=20)

ax.tick_params(axis='y', colors='#e2e8f0', labelsize=10)
ax.yaxis.grid(True, linestyle='--', alpha=0.3, color='#334155')
ax.set_axisbelow(True)

for spine in ax.spines.values():
    spine.set_color('#334155')

# Net retention annotation
gross_add = 18.5 + 12.2 + 2.8
gross_loss = 4.5 + 8.2
ndr = ((initial - 4.5 - 8.2 + 12.2) / initial) * 100
ax.annotate(f'Net Dollar Retention: {ndr:.0f}%', xy=(0.98, 0.95), xycoords='axes fraction',
            fontsize=11, color='#6CF527', ha='right', fontweight='bold',
            bbox=dict(boxstyle='round,pad=0.4', facecolor='#1e293b', edgecolor='#6CF527', alpha=0.9))

# Legend outside plot
legend_elements = [
    Patch(facecolor='#27D3F5', label='Beginning ARR'),
    Patch(facecolor='#27F5B0', label='ARR Growth'),
    Patch(facecolor='#F5276C', label='ARR Loss'),
    Patch(facecolor='#6CF527', label='Ending ARR')
]
ax.legend(handles=legend_elements, loc='upper left', bbox_to_anchor=(0, -0.1), 
          ncol=4, fontsize=9, facecolor='#1e293b', edgecolor='#334155', labelcolor='white')

plt.tight_layout()
plt.subplots_adjust(bottom=0.15)
plt.show()
Library

Matplotlib

Category

Financial

Did this help you?

Support PyLucid to keep it free & growing

Support