Technical writing

SEC N-PORT Mutual Fund Holdings: The Federal Database Behind Every Fund Portfolio Position

· 12 min read· AI Analytics
SECN-PORTMutual FundsETFsFederal Data

Form N-PORT is the monthly portfolio-holdings report that every registered open-end fund, exchange-traded fund, and most closed-end funds must file with the SEC under the Investment Company Act — a position-by-position X-ray of what each fund actually owns. The public filings expose the complete security-level composition of the US fund industry: in the dataset described here, 354,405 individual holding rows, each carrying the security's identifiers, its value in US dollars, its weight as a percent of net assets, its asset category and country of investment, and the fair-value hierarchy level that signals how liquid — or how illiquid — the position really is.

What It Is

Form N-PORT is the SEC's structured monthly portfolio-holdings report for registered investment companies. It was adopted in October 2016 as part of a broader modernization of investment-company reporting, with compliance phased in between June 2019 and 2021 depending on fund-complex size. It replaced Form N-Q, the prior quarterly portfolio schedule, which had been filed as an unstructured document and offered no machine-readable position data. Where N-Q gave the public a static PDF-style schedule four times a year, N-PORT delivers a granular, fielded XML record of every holding, every month.

The reporting obligation reaches almost the entire registered fund universe. Every open-end management investment company (the legal form of an ordinary mutual fund), every exchange-traded fund organized as an open-end fund or unit investment trust, and most closed-end funds must file. The only significant carve-outs are money market funds, which report separately on Form N-MFP, and small business investment companies. A single large fund complex — a Vanguard, a Fidelity, a BlackRock — files on behalf of dozens or hundreds of distinct fund “series,” each of which is an individual fund with its own portfolio, its own series identifier, and its own row set in the data.

The filing cadence is the detail that most often trips up new users of the data. Funds prepare an N-PORT report every month, but they file all three monthly reports for a quarter together, within 60 days of quarter end. Critically, only the report for the third month of each quarter is made public. That public version carries the EDGAR form type NPORT-P — the “-P” suffix denotes the public filing. The first two monthly reports of each quarter are submitted to the SEC on a non-public basis and are not disclosed to investors. So while the agency receives twelve monthly snapshots a year per fund, the public sees four: the holdings as of the last day of March, June, September, and December.

The Schema

Each row of the holdings table represents one security held by one fund series as of one report period. The schema is built around three things: identifying the security, sizing the position, and classifying its economic character. The table described here contains 354,405 such rows across the funds and periods it covers.

-- sec_nport_holdings: 354,405 position-level rows
-- One row per security held, per fund series, per report period.

registrant_cik       INTEGER  -- CIK of the registrant (the fund company / trust)
series_id            TEXT     -- SEC series identifier, e.g. "S000001234" (one fund)
report_period        DATE     -- the as-of date of the holdings snapshot (month end)
name                 TEXT     -- issuer / security name as reported by the fund
lei                  TEXT     -- Legal Entity Identifier of the issuer (20-char ISO 17442)
title                TEXT     -- title of the issue (e.g. "5.25% Senior Notes due 2031")
cusip                TEXT     -- 9-character CUSIP identifier
isin                 TEXT     -- 12-character ISIN identifier
ticker               TEXT     -- exchange ticker, where applicable
balance              NUMERIC  -- quantity held (shares, or principal amount for debt)
units                TEXT     -- unit of the balance: NS (number of shares), PA (principal)
value_usd            NUMERIC  -- fair value of the position in US dollars
pct_of_net_assets    NUMERIC  -- position value as a percent of the fund's net assets
payoff_profile       TEXT     -- "Long" or "Short" exposure
asset_category       TEXT     -- EC (equity-common), DBT (debt), DCO (derivative-commodity), ...
issuer_category      TEXT     -- CORP (corporate), USGSE (US govt sponsored), RF (registered fund), ...
investment_country   TEXT     -- ISO country code of the investment (US, GB, JP, KY, ...)
is_restricted        BOOLEAN  -- TRUE if the security is a restricted (Rule 144 / 144A) security
fair_value_level     INTEGER  -- 1, 2, or 3 under the ASC 820 fair-value hierarchy

The identity fields are layered deliberately, because no single identifier covers every instrument. The cusip is the nine-character standard for North American securities; the isin is the twelve-character international standard that embeds a country prefix; the ticker is the exchange symbol, present mostly for listed equities and ETFs; and the lei — the twenty-character Legal Entity Identifier defined by ISO 17442 — identifies the issuer rather than the instrument, which makes it the cleanest key for rolling up every bond, note, and share class back to a single corporate parent. The name and title fields carry the human-readable issuer name and the specific issue description (for a bond, its coupon and maturity).

The sizing fields connect the raw quantity to its economic value. The balance is the quantity held, and units tells you how to read it: NS means a number of shares, while PA means a principal amount (the convention for debt). The value_usd field carries the fair value of the position translated into US dollars, and pct_of_net_assets expresses that value as a fraction of the fund's total net assets — the single most useful field for comparing position sizes across funds of wildly different scale.

The classification fields describe what kind of exposure the position is. The payoff_profile records whether the exposure is Long or Short. The asset_category bins the holding into the SEC's taxonomy — equity-common, equity-preferred, corporate debt, government debt, several flavors of derivative, repurchase agreements, and others — while issuer_category classifies the issuer itself (corporate, US government, US government-sponsored entity, registered fund, municipal, and so on). The investment_country field carries the ISO country code for the investment, which is what lets an analyst measure a supposedly domestic fund's true foreign exposure. The is_restricted flag marks securities subject to resale restrictions (Rule 144 or 144A paper), and fair_value_level places the position on the three-tier fair-value hierarchy discussed next.

The Fair Value Hierarchy

The fair_value_level field is, for risk analysis, the most quietly informative column in the entire schema. It reports where each position sits in the three-level fair-value hierarchy defined by US accounting standard ASC 820 (formerly FAS 157). The hierarchy ranks valuations not by how risky the asset is, but by how observable the inputs to its valuation are — in other words, how much the reported price depends on a real market versus a model.

This is why Level 3 concentration is a valuation-risk signal rather than a curiosity. A fund whose holdings are overwhelmingly Level 1 reports a net asset value that an outside party could substantially reproduce from public market data. A fund with a large Level 3 bucket reports a NAV that depends materially on management's own judgment, which introduces three concrete hazards: the marks can be stale (an estimate updated infrequently rather than a price ticking in real time), the marks can be optimistic (there is discretion, and discretion has a direction), and the positions can be hard to sell at the marked price under stress — the moment redemptions spike is exactly when illiquid positions prove unsellable near their carrying value. Computing the Level 3 percentage of net assets across funds is one of the most direct screens the N-PORT data supports, and it is the centerpiece of the worked example later in this article.

Liquidity, Derivatives, and Securities Lending

N-PORT does not exist in isolation; it is the data backbone for several post-2008 fund-risk rules, and the schema carries the fields those rules need.

The Liquidity Rule (Rule 22e-4). Adopted alongside N-PORT, Rule 22e-4 requires open-end funds to run a liquidity risk management program and to classify each holding into liquidity buckets — highly liquid, moderately liquid, less liquid, and illiquid — based on how many days it would take to convert the position to cash without materially moving its price. Funds are capped at 15 percent of net assets in illiquid investments. The liquidity classifications are reported within the broader N-PORT framework, and they dovetail with the fair-value level: Level 3 positions and illiquid-bucket positions overlap heavily, and reading them together gives a far sharper picture of redemption risk than either field alone.

Derivatives. Because asset_category and payoff_profile distinguish derivative exposures and long-versus-short direction, N-PORT captures a fund's derivative book in a way the old N-Q schedule never did. The full filing carries additional derivative-specific schedules — notional amounts, counterparties, reference instruments, and the like — that support Rule 18f-4, the SEC's derivatives rule, which limits a fund's leverage-related risk using a value-at-risk test. A short payoff profile on a holding row signals an offsetting or hedging position rather than ownership, and netting long against short exposure to the same issuer is a routine step in reconstructing true economic exposure.

Securities lending. Funds that lend portfolio securities to short sellers and other borrowers for incremental income disclose that activity within the filing, including the value of securities on loan and the cash collateral reinvestment vehicle. For an analyst, securities-lending disclosure matters because it reveals a layer of counterparty and reinvestment risk that the bare holdings list would otherwise hide, and because a position out on loan is not freely available for sale at a moment's notice.

What You Can Do With It

The combination of full coverage, position-level granularity, and a consistent schema makes N-PORT one of the highest-leverage public datasets in finance. A few of the analyses it supports directly:

N-PORT Versus Form 13F

N-PORT is frequently confused with Form 13F, and the two are genuinely complementary rather than redundant. Both are public SEC holdings disclosures, but they describe different entities, different assets, and different time grids.

DimensionForm N-PORT (NPORT-P)Form 13F
Who filesRegistered funds — mutual funds, ETFs, most closed-end fundsInstitutional investment managers with over $100M in 13(f) securities
Unit of disclosureThe individual fund series (its complete portfolio)The manager, aggregated across all the accounts it controls
Asset scopeAll asset classes — equity, debt, derivatives, cash, reposOnly 13(f) securities — chiefly US-listed equities and options
Position weightReported as percent of net assets per positionNot reported; only share counts and market value
Liquidity / valuation levelFair-value level and liquidity classification includedNot reported
Public frequencyMonthly snapshot, with the quarter-end month published quarterlyQuarterly

The practical division of labor: use 13F to see what a manager — a hedge fund, a pension, an advisor — holds in US equities across everything it runs, and use N-PORT to see what a specific fund product holds across every asset class with full weights and liquidity context. A complete picture of, say, a large asset manager often requires both: 13F for the equity sleeve at the firm level, N-PORT for the all-asset detail at the fund level. A companion article on this site covers the 13F dataset in its own right.

Python: Reconstructing a Fund Portfolio From EDGAR

The script below walks the full N-PORT workflow against the live SEC EDGAR system: it enumerates a registrant's public NPORT-P filings through the submissions API, downloads the primary XML for the most recent one, parses every holding block into a flat record, and then aggregates the portfolio by asset category, computes the Level 3 percentage of net assets, and ranks the largest positions by their weight. The same parser scales to the full public corpus by iterating across registrant CIKs and filing periods.

import io
import csv
import time
import requests
import xml.etree.ElementTree as ET
from collections import defaultdict

# ---------------------------------------------------------------------------
# SEC EDGAR Form N-PORT (NPORT-P) Portfolio Reconstruction
# Sources:
#   Submissions API:  https://data.sec.gov/submissions/CIK{cik:010d}.json
#   Filing archive:   https://www.sec.gov/Archives/edgar/data/{cik}/{accession}/
#   Full-text search: https://efts.sec.gov/LATEST/search-index?forms=NPORT-P
#
# Strategy:
#   1. Resolve a registrant CIK and enumerate its public NPORT-P filings.
#   2. Download the primary XML (primary_doc.xml) for the most recent filing.
#   3. Parse every <invstOrSec> holding block into a flat record.
#   4. Aggregate the portfolio by asset_category, compute the Level 3 share,
#      and rank the largest positions by percent of net assets.
# ---------------------------------------------------------------------------

HEADERS = {"User-Agent": "research@example.com (nport research project)"}
BASE     = "https://www.sec.gov"
DATA_API = "https://data.sec.gov"

# N-PORT XML uses a default namespace; strip it to keep XPath simple.
def strip_ns(root):
    for el in root.iter():
        if "}" in el.tag:
            el.tag = el.tag.split("}", 1)[1]
    return root


# -- 1. Enumerate NPORT-P accession numbers for a registrant CIK --------------

def get_nport_accessions(cik10, max_filings=12):
    """Return recent NPORT-P submission records for a registrant CIK."""
    url  = DATA_API + "/submissions/CIK" + cik10 + ".json"
    data = requests.get(url, headers=HEADERS, timeout=30).json()
    recent = data.get("filings", {}).get("recent", {})

    forms    = recent.get("form", [])
    accns    = recent.get("accessionNumber", [])
    docs     = recent.get("primaryDocument", [])
    filed    = recent.get("filingDate", [])

    out = []
    for form, acc, doc, fdate in zip(forms, accns, docs, filed):
        if form != "NPORT-P":
            continue
        out.append({"accession": acc, "primary_doc": doc, "filed": fdate})
        if len(out) >= max_filings:
            break
    return out


# -- 2. Build the archive URL for the primary XML document --------------------

def nport_xml_url(cik, accession, primary_doc):
    acc_nodash = accession.replace("-", "")
    return (BASE + "/Archives/edgar/data/" + str(int(cik)) + "/"
            + acc_nodash + "/" + primary_doc)


# -- 3. Parse holdings from the NPORT-P primary XML ---------------------------

def parse_holdings(xml_text):
    """Parse every holding (<invstOrSec>) into a flat dict."""
    root = strip_ns(ET.fromstring(xml_text))

    gen = root.find(".//genInfo")
    series_id = (gen.findtext("seriesId") if gen is not None else "") or ""
    rep_pd    = (gen.findtext("repPdDate") if gen is not None else "") or ""

    rows = []
    for sec in root.findall(".//invstOrSec"):
        ids = sec.find("identifiers")
        cusip = sec.findtext("cusip") or ""
        isin = ticker = ""
        if ids is not None:
            isin_el   = ids.find("isin")
            ticker_el = ids.find("ticker")
            if isin_el is not None:
                isin = isin_el.get("value", "")
            if ticker_el is not None:
                ticker = ticker_el.get("value", "")

        def num(tag):
            raw = sec.findtext(tag)
            try:
                return float(raw)
            except (TypeError, ValueError):
                return 0.0

        rows.append({
            "series_id":          series_id,
            "report_period":      rep_pd,
            "name":               sec.findtext("name") or "",
            "lei":                sec.findtext("lei") or "",
            "title":              sec.findtext("title") or "",
            "cusip":              cusip,
            "isin":               isin,
            "ticker":             ticker,
            "balance":            num("balance"),
            "units":              sec.findtext("units") or "",
            "value_usd":          num("valUSD"),
            "pct_of_net_assets":  num("pctVal"),
            "payoff_profile":     sec.findtext("payoffProfile") or "",
            "asset_category":     sec.findtext("assetCat") or "",
            "issuer_category":    sec.findtext("issuerCat") or "",
            "investment_country": sec.findtext("invCountry") or "",
            "is_restricted":      (sec.findtext("isRestrictedSec") or "N") == "Y",
            "fair_value_level":   sec.findtext("fairValLevel") or "",
        })
    return rows


# -- 4. Aggregate one fund portfolio -----------------------------------------

def summarize(rows):
    total_val = sum(r["value_usd"] for r in rows) or 1.0

    by_asset = defaultdict(float)
    for r in rows:
        by_asset[r["asset_category"] or "(none)"] += r["value_usd"]

    level3_val = sum(r["value_usd"] for r in rows if r["fair_value_level"] == "3")
    restricted_val = sum(r["value_usd"] for r in rows if r["is_restricted"])

    return {
        "positions":      len(rows),
        "total_value":    total_val,
        "by_asset":       dict(sorted(by_asset.items(), key=lambda kv: -kv[1])),
        "level3_pct":     100.0 * level3_val / total_val,
        "restricted_pct": 100.0 * restricted_val / total_val,
    }


# -- Main --------------------------------------------------------------------

CIK = 36405  # example registrant trust CIK; replace with any fund company CIK
cik10 = str(CIK).zfill(10)

print("Fetching NPORT-P filings for CIK " + cik10 + " ...")
filings = get_nport_accessions(cik10, max_filings=4)
print("  Found " + str(len(filings)) + " public NPORT-P filings")

if not filings:
    raise SystemExit("No public NPORT-P filings found for this registrant.")

latest = filings[0]
url = nport_xml_url(CIK, latest["accession"], latest["primary_doc"])
print("Downloading " + url)
xml_text = requests.get(url, headers=HEADERS, timeout=60).text
time.sleep(0.2)  # respect SEC fair-access rate limits

rows = parse_holdings(xml_text)
summary = summarize(rows)

print("\nSeries: " + (rows[0]["series_id"] if rows else "?")
      + "   as of " + (rows[0]["report_period"] if rows else "?"))
print("Positions: " + str(summary["positions"]))
print("Total reported value: $" + format(summary["total_value"], ",.0f"))
print("Level 3 (unobservable inputs): " + format(summary["level3_pct"], ".2f") + "%")
print("Restricted securities:         " + format(summary["restricted_pct"], ".2f") + "%")

print("\nAllocation by asset category:")
for cat, val in summary["by_asset"].items():
    pct = 100.0 * val / summary["total_value"]
    bar = "#" * int(pct / 2)
    print("  " + cat.ljust(6) + format(pct, "6.2f") + "%  " + bar)

print("\nTop 10 positions by percent of net assets:")
rows.sort(key=lambda r: r["pct_of_net_assets"], reverse=True)
for r in rows[:10]:
    name = r["name"][:38].ljust(40)
    pct  = format(r["pct_of_net_assets"], "6.2f")
    lvl  = r["fair_value_level"] or "-"
    print("  " + name + pct + "%   L" + lvl + "   " + (r["ticker"] or r["cusip"]))

A few implementation notes. N-PORT primary documents use an XML default namespace, so the strip_ns helper removes it to keep the XPath expressions readable; an alternative is to carry the namespace map through every find call. The submissions API returns only the most recent filings inline and pages older history into a separate files array that requires follow-up requests, so a full historical pull must walk those pages. The element names in the example (invstOrSec, valUSD, pctVal, assetCat, fairValLevel, and so on) follow the SEC's N-PORT XML technical specification; consult the current specification when extending the parser to the derivative, securities-lending, and liquidity sub-schedules, which carry their own nested element structures. As with all EDGAR access, the SEC's fair-access policy requires a descriptive User-Agent header with a contact email and a request rate no higher than ten per second.

Caveats

The public lag is real. Funds have 60 days after quarter end to file, and only the quarter-end month is disclosed. By the time an NPORT-P appears on EDGAR, the holdings it describes can be up to two months old, and the two intervening monthly snapshots the SEC holds are never published. Any strategy or risk model built on this data is working with a deliberately delayed, deliberately thinned view, and should never be mistaken for a real-time portfolio.

Month-end snapshots miss intra-quarter trading. A holding row is a still photograph taken on the last day of the quarter. A fund that bought a position the day after one quarter end and sold it the day before the next will show nothing — the entire round trip falls between snapshots. “Window dressing,” the practice of reshaping a portfolio just before a reporting date to present a more flattering book, is a known consequence: the disclosed holdings can systematically differ from what the fund held during the quarter.

Identifier gaps complicate joins. Not every instrument carries a clean CUSIP, ISIN, ticker, and LEI. Derivatives, private placements, foreign instruments, and cash equivalents frequently have one or more identifier fields blank, and issuer names are entered as free text with inconsistent spelling, abbreviation, and legal-suffix conventions across filers. Rolling holdings up to a common issuer therefore requires careful identifier fallback logic — preferring LEI, then ISIN, then CUSIP, then normalized name — and even then a residue of unmatched positions remains. Treating the identifier columns as uniformly populated is the most common source of error in N-PORT analysis.


Related writing

For the institutional-manager counterpart — quarterly US-equity positions reported at the firm level rather than fund-level all-asset holdings — see SEC Form 4 Insider Trading: The Federal Database Behind Corporate Insider Stock Transactions.

For the private-markets analogue to public-fund disclosure — the exempt-offering database covering trillions in annual private placements — see SEC Form D: The Private Placement Database Behind $2 Trillion in Annual Exempt Offerings.

For another high-coverage federal dataset built on a consistent fielded schema — wage and employment data for every US occupation — see BLS Occupational Employment and Wage Statistics: The Federal Database Behind Median Salary Data for Every US Occupation.