Multi-Period Financial Analysis with XBRLS
Overview
Multi-period financial analysis allows you to compare a company's performance across multiple years or quarters. The XBRLS class in edgartools makes this easy by automatically stitching together financial statements from multiple SEC filings.
Why Use Multi-Period Analysis?
Financial analysts need to see trends over time: - Revenue growth over 3-5 years - Margin expansion or compression - Balance sheet evolution - Cash flow patterns
When to Use XBRLS vs Single XBRL
| Use Case | Tool | Why |
|---|---|---|
| Analyze current quarter | XBRL.from_filing() |
One filing, faster |
| Compare 2+ periods | XBRLS.from_filings() |
Multi-filing stitching |
| Historical trends (3-5 years) | XBRLS.from_filings() |
Handles concept changes |
| Quick annual comparison | Company.income_statement() |
EntityFacts API (simpler) |
Key Difference: XBRLS works with individual filings and stitches them together, preserving the original XBRL structure. The Company API uses the EntityFacts API, which is pre-aggregated by the SEC but may have different period selections.
Quick Example
Here's how to analyze Apple's revenue trend over 3 years:
from edgar import Company
from edgar.xbrl import XBRLS
# Get the last 3 annual filings
company = Company("AAPL")
filings = company.get_filings(form="10-K").head(3)
# Create XBRLS object (automatically stitches statements)
xbrls = XBRLS.from_filings(filings)
# Access stitched income statement
income = xbrls.statements.income_statement()
print(income)
# Or convert to DataFrame for analysis
df = income.to_dataframe()
# The DataFrame has a `standard_concept` column for stable, cross-year access.
# Index by it and select the period (date) columns:
periods = [c for c in df.columns if c.startswith("20")] # e.g. ['2025-09-27', '2024-09-28', '2023-09-30']
df = df.set_index("standard_concept")
print(df.loc["Revenue", periods])
This automatically: - Parses XBRL from all 3 filings - Aligns periods correctly - Handles concept name changes between years - Creates a unified view
Getting Started with XBRLS
Creating an XBRLS Object
There are two ways to create an XBRLS object:
Method 1: From Filings (Recommended)
from edgar import Company
from edgar.xbrl import XBRLS
# Get multiple filings
company = Company("MSFT")
filings = company.get_filings(form="10-K").latest(3)
# Create XBRLS
xbrls = XBRLS.from_filings(filings)
Method 2: From XBRL Objects
from edgar.xbrl import XBRL, XBRLS
# If you already have XBRL objects
xbrl_list = [XBRL.from_filing(f) for f in filings]
xbrls = XBRLS.from_xbrl_objects(xbrl_list)
Understanding What XBRLS Does
When you create an XBRLS object, it:
- Collects all periods from each filing
- Identifies optimal periods (e.g., fiscal year-ends)
- Normalizes concept names (e.g., "Total Revenue" vs "Net Sales")
- Aligns values across periods
- Fills gaps when a line item appears in some years but not others
Accessing Stitched Statements
The statements property provides a simple interface to all statement types:
# Get stitched statements
balance_sheet = xbrls.statements.balance_sheet()
income_statement = xbrls.statements.income_statement()
cash_flow = xbrls.statements.cash_flow_statement()
# Print statements (uses rich formatting)
print(balance_sheet)
print(income_statement)
Available statement methods:
- balance_sheet() - Assets, Liabilities, Equity
- income_statement() - Revenue, Expenses, Net Income
- cash_flow_statement() - Operating, Investing, Financing Cash Flows
- statement_of_equity() - Changes in shareholders' equity
- comprehensive_income() - Other comprehensive income items
How Stitching Works Conceptually
Think of stitching as creating a unified view across filings:
Filing 1 (2024 10-K) Filing 2 (2023 10-K) Filing 3 (2022 10-K)
------------------ ------------------ ------------------
Revenue: $100M (2024) Revenue: $85M (2023) Net Sales: $70M (2022)
COGS: $60M COGS: $50M COGS: $42M
------------------ ------------------ ------------------
XBRLS Stitching Process
↓
Unified Statement (Most Recent → Oldest)
----------------------------------------
Revenue: $100M (2024) | $85M (2023) | $70M (2022)
COGS: $60M | $50M | $42M
Notice: - "Net Sales" in 2022 was recognized as "Revenue" (concept normalization) - Periods are aligned by fiscal year-end - Labels use the most recent terminology
Working with Multi-Period DataFrames
Converting to DataFrame
DataFrames are ideal for quantitative analysis:
# Get income statement as DataFrame
df = income.to_dataframe()
# DataFrame structure (RangeIndex — 0, 1, 2, ...):
# - `label` : the company's own line-item wording (e.g. "Net sales")
# - `concept` : the raw XBRL concept (e.g. "us-gaap_RevenueFromContract...")
# - `standard_concept` : the standardized concept for cross-company access (e.g. "Revenue")
# - one column per period, keyed by fiscal year-end date (e.g. "2025-09-27")
# - `preferred_sign` : display-sign hint
print(df.head())
Example output (columns trimmed for readability):
label standard_concept 2025-09-27 2024-09-28 2023-09-30
0 Net sales Revenue 416161000000 391035000000 383285000000
1 Cost of sales CostOfGoodsAndServicesSold ...
2 Gross margin GrossProfit ...
3 Total operating expenses TotalOperatingExpenses ...
4 Operating income OperatingIncomeLoss ...
Understanding Column Structure
The DataFrame is integer-indexed. Line items are identified by the label and
standard_concept columns, not by the index. Period columns use fiscal year-end dates:
# Examine the period (date) columns
periods = [c for c in df.columns if c.startswith("20")]
print(periods)
# ['2025-09-27', '2024-09-28', '2023-09-30']
# Index by standard_concept for stable, cross-year access
df = df.set_index("standard_concept")
# Access a specific period
revenue_2025 = df.loc["Revenue", "2025-09-27"]
# Access all periods for a line item
revenue_trend = df.loc["Revenue", periods]
print(revenue_trend)
Tip:
standard_conceptvalues are stable across companies and years (Revenue,GrossProfit,OperatingIncomeLoss,NetIncome, ...). Thelabelcolumn preserves each company's original presentation, so uselabelwhen you want the filer's exact wording andstandard_conceptwhen you want portable code.
Working with Dimensions
By default, XBRLS excludes dimensional (segment) data for cleaner consolidated statements. Use the view parameter to control this:
# Include dimensional breakdown (e.g., by product line)
income = xbrls.statements.income_statement(view="detailed")
df = income.to_dataframe()
# Now you'll see rows like:
# Revenue [Americas]
# Revenue [Europe]
# Revenue [Asia]
# Summary view — non-dimensional totals only
income_summary = xbrls.statements.income_statement(view="summary")
The view parameter accepts three values:
| View | Description | Use When |
|---|---|---|
"standard" |
Face presentation (default) | Consolidated company-level analysis, trend analysis |
"detailed" |
All dimensional data included | Segment performance, geographic breakdown, product line analysis |
"summary" |
Non-dimensional totals only | Quick overview of main line items |
The legacy include_dimensions boolean is still supported (include_dimensions=True is equivalent to view="detailed"), but view is the preferred API.
Period Selection
Automatic Optimal Period Selection
By default, XBRLS selects the best periods for comparison:
# Automatically selects 3 annual periods
xbrls = XBRLS.from_filings(filings) # filings = 3 annual reports
income = xbrls.statements.income_statement()
# XBRLS picks the fiscal year-end period from each filing
# For Apple: Sep 30, Sep 24, Sep 25 (Saturday year-ends)
How XBRLS Selects Periods:
- Identifies fiscal year-end from each filing's document period end date
- Prefers annual periods (duration > 300 days) over quarterly
- Sorts newest first for trend analysis
- De-duplicates periods that appear in multiple filings
Controlling Period Count
Use max_periods to control how many periods appear:
# Get 5 years instead of 3
filings = company.get_filings(form="10-K").head(5)
xbrls = XBRLS.from_filings(filings)
# Limit to 5 periods even if more are available
income = xbrls.statements.income_statement(max_periods=5)
# Or get all available periods
income = xbrls.statements.income_statement(max_periods=10)
Quarterly Analysis
For quarterly trends, use 10-Q filings:
# Get last 8 quarters
filings = company.get_filings(form="10-Q").head(8)
xbrls = XBRLS.from_filings(filings)
# Quarterly income statement
income = xbrls.statements.income_statement(max_periods=8)
print(income)
Manual Period Inspection
To see what periods are available:
# Get all available periods
periods = xbrls.get_periods()
for period in periods:
print(f"Type: {period['type']}")
print(f"Label: {period['label']}")
if period['type'] == 'duration':
print(f"Duration: {period['days']} days")
print()
# Get just the end dates
end_dates = xbrls.get_period_end_dates()
print(end_dates)
# ['2024-09-28', '2023-09-30', '2022-09-24']
Common Use Cases
1. Revenue Trend Analysis
Track revenue growth over time:
from edgar import Company
from edgar.xbrl import XBRLS
company = Company("AAPL")
filings = company.get_filings(form="10-K").head(5)
xbrls = XBRLS.from_filings(filings)
# Get income statement
income = xbrls.statements.income_statement(max_periods=5)
df = income.to_dataframe()
periods = [c for c in df.columns if c.startswith("20")]
df = df.set_index("standard_concept")
# Extract revenue trend (oldest -> newest for a natural growth series)
revenue = df.loc["Revenue", periods[::-1]]
print(revenue)
# Calculate year-over-year growth
yoy_growth = revenue.pct_change() * 100
print("\nYear-over-Year Growth:")
print(yoy_growth)
2. Margin Analysis Over Time
Compare profitability trends:
# Get income statement
df = income.to_dataframe()
periods = [c for c in df.columns if c.startswith("20")]
df = df.set_index("standard_concept")
# Calculate gross margin for each period
revenue = df.loc["Revenue", periods]
gross_profit = df.loc["GrossProfit", periods]
gross_margin = (gross_profit / revenue) * 100
print("Gross Margin Trend:")
print(gross_margin)
# Operating margin
operating_income = df.loc["OperatingIncomeLoss", periods]
operating_margin = (operating_income / revenue) * 100
print("\nOperating Margin Trend:")
print(operating_margin)
3. Balance Sheet Evolution
Track how balance sheet composition changes:
# Get balance sheet
balance = xbrls.statements.balance_sheet(max_periods=5)
df = balance.to_dataframe()
periods = [c for c in df.columns if c.startswith("20")]
df = df.set_index("standard_concept")
# Asset composition
total_assets = df.loc["Assets", periods]
cash = df.loc["CashAndMarketableSecurities", periods]
cash_ratio = (cash / total_assets) * 100
print("Cash as % of Total Assets:")
print(cash_ratio)
# Leverage analysis
total_liabilities = df.loc["Liabilities", periods]
equity = df.loc["AllEquityBalance", periods]
debt_to_equity = total_liabilities / equity
print("\nDebt-to-Equity Ratio:")
print(debt_to_equity)
4. Cash Flow Pattern Analysis
Understand cash generation and usage:
# Get cash flow statement
cash_flow = xbrls.statements.cash_flow_statement(max_periods=5)
df = cash_flow.to_dataframe()
periods = [c for c in df.columns if c.startswith("20")]
df = df.set_index("standard_concept")
# Operating cash flow trend
operating_cf = df.loc["NetCashFromOperatingActivities", periods]
print("Operating Cash Flow:")
print(operating_cf)
# Free cash flow (Operating CF - Capex). CapitalExpenses is reported as a
# positive outflow here, so subtract it.
capex = df.loc["CapitalExpenses", periods]
free_cash_flow = operating_cf - capex
print("\nFree Cash Flow:")
print(free_cash_flow)
# Cash conversion ratio
income_df = income.to_dataframe().set_index("standard_concept")
net_income = income_df.loc["NetIncome", periods]
cash_conversion = (operating_cf / net_income) * 100
print("\nCash Conversion (Operating CF / Net Income):")
print(cash_conversion)
5. Year-over-Year Comparative Analysis
Compare specific line items across years:
import pandas as pd
# Get 3 years of data
filings = company.get_filings(form="10-K").head(3)
xbrls = XBRLS.from_filings(filings)
# Create comparison DataFrame
income_df = xbrls.statements.income_statement().to_dataframe()
periods = [c for c in income_df.columns if c.startswith("20")]
income_df = income_df.set_index("standard_concept")
# Select key metrics by standardized concept
key_metrics = [
'Revenue',
'GrossProfit',
'OperatingIncomeLoss',
'NetIncome',
]
comparison = income_df.loc[key_metrics, periods]
# Add year-over-year changes (columns are newest -> oldest)
for i in range(len(periods) - 1):
current_col = periods[i]
prior_col = periods[i + 1]
change_col = f"{current_col[:4]} vs {prior_col[:4]}"
comparison[change_col] = (
(comparison[current_col] - comparison[prior_col]) / comparison[prior_col] * 100
)
print(comparison)
Comparison: XBRLS vs Company API
Both XBRLS and the Company API can provide multi-period statements, but they serve different purposes:
Feature Comparison
| Feature | XBRLS | Company.income_statement() |
|---|---|---|
| Data Source | Individual XBRL filings | EntityFacts API |
| Setup Complexity | More code | One-liner |
| Flexibility | High (custom periods) | Medium (predefined periods) |
| Period Selection | Filing-based | API-aggregated |
| Concept Stitching | Automatic | Pre-aggregated by SEC |
| Speed | Slower (parsing XBRL) | Faster (JSON API) |
| Dimensions | Full control | Limited access |
| Offline Use | Possible with caching | Requires API access |
| Best For | Deep analysis, custom periods | Quick lookups, standard views |
When to Use XBRLS
Use XBRLS when you need: - Full control over period selection - Access to filing-specific details - Custom stitching logic - Dimensional segment analysis - To work with specific filings (e.g., amended returns)
Example:
from edgar.xbrl import XBRLS
# Full control over which filings
filings = company.get_filings(form="10-K", filing_date="2020-01-01:2024-12-31").head(4)
xbrls = XBRLS.from_filings(filings)
income = xbrls.statements.income_statement()
When to Use Company API
Use Company API when you need: - Quick standard views - Simple multi-year comparisons - Less code - Faster performance
Example:
from edgar import Company
# Simple and fast
company = Company("AAPL")
income = company.income_statement(period='annual', periods=5)
print(income)
Hybrid Approach
You can use both for different purposes:
from edgar import Company
company = Company("AAPL")
# Quick check with Company API
income_quick = company.income_statement(period='annual', periods=3)
print("Quick view:", income_quick)
# Deep dive with XBRLS
filings = company.get_filings(form="10-K").head(5)
xbrls = XBRLS.from_filings(filings)
income_detailed = xbrls.statements.income_statement(max_periods=5)
df = income_detailed.to_dataframe()
# Now do custom analysis
# ...
Troubleshooting
Missing Periods
Problem: Some periods are missing from stitched statements
# Check available periods
periods = xbrls.get_periods()
print(f"Found {len(periods)} periods")
for p in periods:
print(p)
# Check if filings have XBRL data
for xbrl in xbrls.xbrl_list:
print(f"Entity: {xbrl.entity_name}")
print(f"Period: {xbrl.period_of_report}")
print(f"Statements: {len(xbrl.get_all_statements())}")
Solutions:
- Ensure filings have XBRL data (pre-2009 filings may not)
- Check that filings are the same form type (don't mix 10-K and 10-Q)
- Filter amendments: filings.filter(amendments=False)
Stitching Errors
Problem: Statement fails to stitch or shows unexpected values
# Check individual XBRL objects first
for xbrl in xbrls.xbrl_list:
print(f"\n{xbrl.entity_name} - {xbrl.period_of_report}")
try:
stmt = xbrl.statements.income_statement()
print(stmt)
except Exception as e:
print(f"Error: {e}")
Common causes: - Company changed fiscal year-end - Different statement structures across years - Missing required concepts in some years
Solution: Use standardization metadata (enabled by default):
# Standardization adds standard_concept metadata for cross-company analysis
income = xbrls.statements.income_statement(standard=True)
# Labels always show original company presentation
# Use standard_concept column for filtering/aggregation
df = income.to_dataframe()
print(df[['label', 'standard_concept']].head())
Concept Alignment Issues
Problem: Need to compare similar line items across companies
Use the standard_concept column to identify equivalent concepts:
# Get DataFrame with standard_concept metadata
df = income.to_dataframe()
# Filter by standard concept
revenue_rows = df[df['standard_concept'] == 'Revenue']
# Aggregate by standard concept for comparison
periods = [c for c in df.columns if c.startswith("20")]
standardized = df.groupby('standard_concept')[periods].sum()
Note: Labels preserve the company's original presentation. The
standard_conceptcolumn maps each line item to a standard category for programmatic analysis.
Performance Tips
Problem: Stitching is slow for many filings
# 1. Reduce number of periods
income = xbrls.statements.income_statement(max_periods=3) # Instead of 10
# 2. Filter amendments before creating XBRLS
filings = company.get_filings(form="10-K").filter(amendments=False).head(3)
# 3. Use caching for repeated access
# Statements are cached automatically within XBRLS object
income = xbrls.statements.income_statement() # First call: slow
income = xbrls.statements.income_statement() # Second call: fast (cached)
# 4. For bulk analysis, create XBRLS once and reuse
for statement_type in ['IncomeStatement', 'BalanceSheet', 'CashFlowStatement']:
stmt = xbrls.statements[statement_type]
# ... analyze ...
Advanced Topics
Querying Stitched Facts
For advanced analysis, you can query the underlying facts:
# Query across all filings
query = xbrls.query(max_periods=5)
# Filter to specific concepts
revenue_facts = query.by_standardized_concept("Revenue").execute()
# Convert to DataFrame for analysis
df = query.to_dataframe()
# Filter to concepts across all periods
consistent_facts = query.across_periods(min_periods=5).execute()
Trend Analysis
# Setup trend analysis for specific concept
trend_query = xbrls.query().trend_analysis("Revenue")
# Get results sorted by period
results = trend_query.execute()
# Or get as DataFrame with periods as columns
trend_df = trend_query.to_trend_dataframe()
print(trend_df)
Custom Period Selection
# Get statement data with custom period control
statement_data = xbrls.get_statement(
statement_type='IncomeStatement',
max_periods=5,
standard=True,
use_optimal_periods=True
)
# Examine period structure
print(statement_data['periods'])
# Work with raw data
for item in statement_data['statement_data']:
print(f"{item['label']}: {item['values']}")
Best Practices
1. Always Filter Amendments
Amendments can cause duplicate periods:
# GOOD
filings = company.get_filings(form="10-K").filter(amendments=False).head(5)
# AVOID
filings = company.get_filings(form="10-K").head(5) # May include amendments
2. Use Consistent Form Types
Don't mix annual and quarterly filings:
# GOOD: All 10-K
filings_annual = company.get_filings(form="10-K").head(5)
# GOOD: All 10-Q
filings_quarterly = company.get_filings(form="10-Q").head(8)
# AVOID: Mixed forms
filings_mixed = company.get_filings(form=["10-K", "10-Q"]).head(10)
3. Check Period Alignment
Always verify periods align as expected:
xbrls = XBRLS.from_filings(filings)
# Check periods before analysis
end_dates = xbrls.get_period_end_dates()
print("Analyzing periods:", end_dates)
# Should be consistent fiscal year-ends
# e.g., all December 31 or all September 30
4. Handle Missing Data
Not all line items appear in all periods:
df = income.to_dataframe()
# Check for missing values
print("\nMissing data by period:")
print(df.isnull().sum())
# Fill missing values if appropriate
df_filled = df.fillna(0) # Or use forward-fill: df.ffill()
5. Validate Results
Cross-check with SEC filings:
# Print statement to visually verify
print(income)
# Check against filing
filing = filings[0]
print(f"\nCompare with: {filing.filing_date}")
print(filing.homepage_url)
# Verify key metrics
df = income.to_dataframe()
periods = [c for c in df.columns if c.startswith("20")]
df = df.set_index("standard_concept")
revenue = df.loc["Revenue", periods[0]] # periods[0] is the most recent
print(f"Revenue (most recent): ${revenue:,.0f}")
Related Documentation
- Dimension Handling - Working with segment data
- Standardization Concepts - How concept normalization works
- Choosing the Right API - When to use XBRLS vs the Company/Facts APIs
Summary
Multi-period analysis with XBRLS enables powerful trend analysis:
Key Takeaways:
- Use XBRLS.from_filings() to create multi-period view
- Access statements via xbrls.statements.income_statement()
- Convert to DataFrame with .to_dataframe() for analysis
- Control periods with max_periods parameter
- Always filter amendments for cleaner data
- Use standardization (enabled by default) for consistent labels
Quick Reference:
from edgar import Company
from edgar.xbrl import XBRLS
# Setup
company = Company("AAPL")
filings = company.get_filings(form="10-K").filter(amendments=False).head(5)
xbrls = XBRLS.from_filings(filings)
# Access statements
income = xbrls.statements.income_statement(max_periods=5)
balance = xbrls.statements.balance_sheet(max_periods=5)
cash_flow = xbrls.statements.cash_flow_statement(max_periods=5)
# Convert to DataFrame
df = income.to_dataframe()
periods = [c for c in df.columns if c.startswith("20")]
df = df.set_index("standard_concept")
# Analyze (oldest -> newest for a natural growth series)
revenue_trend = df.loc["Revenue", periods[::-1]]
print(revenue_trend.pct_change() * 100)
For quick lookups, consider the Company API:
# Simpler alternative for standard views
income = company.income_statement(period='annual', periods=5)
Choose XBRLS when you need full control and deep analysis. Use Company API for quick standard views.