A 75-unit portfolio managed the way most operators actually manage it - gut feel at renewal, quick Zillow checks when a unit goes vacant, no systematic process - typically leaves 3-8% of potential NOI on the table annually. On a $180,000 annual gross rent roll, that is $5,400 to $14,400 per year in foregone income. Over a 5-year hold period, that gap compounds into a meaningful drag on returns and a lower exit valuation because NOI is what drives your cap rate sale price.

This post covers the full portfolio rent optimization workflow - from pulling your lease roll to generating a prioritized action queue - using the RentComp API for batch comp pulls and Python for the analysis layer. For context on automating individual pricing decisions, see our post on automating rent pricing with market data. For investment analysis applications, see real estate investment analysis with rental data APIs.

The Scale Problem: Why Manual Pricing Fails at 50+ Units

At 10 units, a diligent operator can check market rent manually for each renewal. It takes time but it is tractable. At 50 units, the math breaks down. If you have 50 units with staggered 12-month leases, you have roughly 4-5 renewals per month. Each requires checking current market, assessing unit condition, calculating the trade-off between higher rent and turnover risk, and communicating with the tenant. Doing this properly for each unit takes 30-45 minutes. That is 2-3 hours of pricing work every month just to tread water - and most operators do not do it, which is why underpricing is so common.

The systematic approach compresses this to 20 minutes per month for a 50-unit portfolio. You run the batch analysis once a week, review the output CSV, make any overrides based on local knowledge, and hand the renewal team a prioritized action list. The API handles the data collection; you handle the judgment.

The Portfolio Rent Audit Workflow

Step 1: Pull the Active Lease Roll

Export your lease roll from your PM system with at minimum these fields per unit:

Most PM systems (Buildium, AppFolio, Rent Manager, Yardi) can export this as CSV in under 2 minutes. Save it as lease_roll.csv.

Step 2: Batch Comp Pulls with Async Python

For 50-200 units, sequential API calls work but take 2-5 minutes. For 200+ units, use asyncio + aiohttp for parallel calls. The rate limit is 10 concurrent requests, which cuts processing time by roughly 8-9x in practice.

import asyncio
import aiohttp
import csv
import json
from datetime import datetime, timedelta
from pathlib import Path

RENTCOMP_API_KEY = "your_api_key"
RENTCOMP_BASE = "https://api.rentcompapi.com/v1"
CONCURRENCY = 8  # stay under the 10 concurrent request limit


async def fetch_comps(session, semaphore, row):
    """Fetch comps for one unit row from lease roll."""
    async with semaphore:
        address = row["address"]
        unit = row.get("unit", "")
        full_address = f"{address} Unit {unit}".strip() if unit else address

        payload = {
            "address": full_address,
            "bedrooms": int(row.get("bedrooms", 1)),
            "bathrooms": float(row.get("bathrooms", 1)),
            "sqft": int(str(row.get("sqft", 800)).replace(",", "")),
            "radius_miles": 1.0,
            "max_age_days": 90,
        }

        try:
            async with session.post(
                f"{RENTCOMP_BASE}/comps",
                json=payload,
                headers={"Authorization": f"Bearer {RENTCOMP_API_KEY}"},
                timeout=aiohttp.ClientTimeout(total=20),
            ) as resp:
                if resp.status != 200:
                    return {**row, "error": f"HTTP {resp.status}",
                            "market_median": None, "confidence": 0}
                data = await resp.json()
                stats = data.get("market_stats", {})
                return {
                    **row,
                    "market_median": stats.get("median"),
                    "market_p25": stats.get("p25"),
                    "market_p75": stats.get("p75"),
                    "confidence": data.get("confidence_score", 0),
                    "comp_count": data.get("comp_count", 0),
                    "error": None,
                }
        except Exception as e:
            return {**row, "error": str(e), "market_median": None,
                    "confidence": 0}


async def batch_portfolio_comps(lease_roll_path: str) -> list:
    """Read lease roll CSV and fetch comps for all active units."""
    rows = []
    with open(lease_roll_path, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            # Normalize column names to lowercase
            rows.append({k.lower().strip(): v.strip()
                          for k, v in row.items()})

    print(f"Processing {len(rows)} units...")
    semaphore = asyncio.Semaphore(CONCURRENCY)

    async with aiohttp.ClientSession() as session:
        tasks = [fetch_comps(session, semaphore, row) for row in rows]
        results = await asyncio.gather(*tasks)

    return results


def calculate_portfolio_analysis(results: list) -> list:
    """Add gap analysis and action classification to each unit."""
    analyzed = []
    for r in results:
        current_rent = float(str(r.get("current_rent", 0)).replace(",", ""))
        market_median = r.get("market_median")
        confidence = r.get("confidence", 0)
        lease_end_str = r.get("lease_end_date", "")

        # Parse lease end date
        lease_end = None
        for fmt in ("%m/%d/%Y", "%Y-%m-%d", "%m/%d/%y"):
            try:
                lease_end = datetime.strptime(lease_end_str, fmt)
                break
            except (ValueError, TypeError):
                continue

        days_to_renewal = None
        if lease_end:
            days_to_renewal = (lease_end - datetime.today()).days

        if market_median and current_rent > 0 and confidence >= 60:
            price_gap = market_median - current_rent
            gap_pct = price_gap / current_rent * 100

            if gap_pct > 10:
                action = "INCREASE >10%"
                priority = 1
            elif gap_pct > 5:
                action = "RENEWAL TARGET"
                priority = 2
            elif gap_pct >= -2:
                action = "HOLD"
                priority = 3
            else:
                action = "AT RISK - REVIEW"  # current rent > market
                priority = 4
        else:
            price_gap = None
            gap_pct = None
            action = "LOW DATA - MANUAL REVIEW"
            priority = 5

        # Boost priority for units renewing soon
        if days_to_renewal is not None and days_to_renewal <= 60:
            priority = max(1, priority - 1)

        analyzed.append({
            **r,
            "current_rent": current_rent,
            "market_median": market_median,
            "price_gap": round(price_gap, 2) if price_gap is not None else None,
            "gap_pct": round(gap_pct, 1) if gap_pct is not None else None,
            "days_to_renewal": days_to_renewal,
            "action": action,
            "priority": priority,
        })

    # Sort by priority then absolute dollar gap descending
    analyzed.sort(key=lambda x: (
        x["priority"],
        -(abs(x["price_gap"]) if x["price_gap"] else 0)
    ))
    return analyzed


def write_portfolio_report(results: list, output_path: str):
    """Write the final analysis to CSV."""
    if not results:
        print("No results to write.")
        return

    fieldnames = [
        "address", "unit", "bedrooms", "bathrooms", "sqft",
        "current_rent", "market_median", "market_p25", "market_p75",
        "price_gap", "gap_pct", "confidence", "comp_count",
        "lease_end_date", "days_to_renewal", "action", "priority", "error"
    ]

    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(results)

    # Print summary stats
    total = len(results)
    increase_10 = sum(1 for r in results if r["action"] == "INCREASE >10%")
    renewal_target = sum(1 for r in results if r["action"] == "RENEWAL TARGET")
    total_gap = sum(r["price_gap"] for r in results
                    if r["price_gap"] and r["price_gap"] > 0)

    print(f"\nPortfolio Summary ({total} units):")
    print(f"  Increase >10%: {increase_10} units")
    print(f"  Renewal target (5-10%): {renewal_target} units")
    print(f"  Total monthly revenue gap: ${total_gap:,.0f}")
    print(f"  Annual revenue gap: ${total_gap * 12:,.0f}")
    print(f"\nOutput written to {output_path}")


# Main entry point
async def main():
    results = await batch_portfolio_comps("lease_roll.csv")
    analyzed = calculate_portfolio_analysis(results)
    write_portfolio_report(analyzed, "portfolio_rent_analysis.csv")


asyncio.run(main())

The Unit Prioritization Matrix

The action queue is not just a list sorted by gap percentage. The most important variable is urgency - a 12% gap on a unit renewing in 45 days needs to be acted on this week. A 15% gap on a unit that just signed a 12-month lease is informational only.

The prioritization matrix combines price_gap with days_to_renewal:

Portfolio-Level Analytics for Investor Reports

The batch output gives you the unit-level data, but ownership and investors care about portfolio-level metrics. The three numbers that matter most:

Revenue gap: Sum of all positive price gaps across the portfolio. This is the total monthly revenue you are leaving on the table if every underpriced unit were brought to market. A 75-unit portfolio with an average $90 gap is leaving $6,750/month or $81,000/year on the table.

Top 10 underpriced units by absolute dollar gap: These are the highest-priority units by NOI impact. Include address, current rent, market median, and days to renewal. This is the action list that goes to the property manager.

Vacancy drag: Units that have been vacant more than 21 days are likely overpriced for current market conditions. Flag these with their market median - the right question is not "what is market" but "what is market minus one month's concession to clear it fast."

Seasonal Pricing Strategy for Portfolio Operators

One underappreciated lever for portfolio operators is lease expiration timing. If all your leases expire in December and January, you are forced to re-lease in the softest rental months of the year. The rental market in most metros runs 8-12% softer in November through February than in May through August. Staggering lease expirations so that 60-70% expire in spring and summer can be worth more than any individual rent increase.

The practical approach: when a tenant renews mid-cycle, offer a slight discount (1-2%) for a 14-month or 15-month lease rather than a 12-month lease. This shifts their next renewal into the spring premium window. Over 2-3 lease cycles, you can meaningfully rebalance your expiration calendar without forcing any tenant out.

When to Override the API Recommendation

The API gives you the market anchor. It does not know everything about your specific unit. Override the recommendation when:

The NOI math: On a 75-unit portfolio with a 6.5% cap rate, every $100/month of additional rent per unit is worth $100 x 12 months x 75 units / 0.065 = $1,384,615 in asset value at exit. Getting your rent roll to market is not a maintenance task - it is a wealth creation task.

Ready to Pull Rental Comps via API?

Join the waitlist and get 80% off founding member pricing - for life.

Join the Waitlist