Written for someone who can already program. This is the shape of the platform and the handful of constraints that produce almost every error you'll hit.
The object model, minimally
Application the Revit session
Document one .rvt (or .rfa)
Element everything is an Element
├─ ElementType the "type" (Category → Family → Type → Instance)
├─ Parameter named values on an element
├─ Category classification
├─ View a view is an Element too
└─ Geometry GeometryElement → Solid → Face → Edge → Curve
UIApplication / UIDocument the UI wrapper — selection, prompts, active viewTwo things to internalize:
- Everything is an
Elementwith anElementId. Views, levels, materials, sheets, and view templates are all elements in the document. Documentis model access;UIDocumentis user access. You needUIDocumentfor selection and prompting,Documentfor everything else. Add-ins get both fromExternalCommandData.
Rule 1: all model changes happen inside a Transaction
using (Transaction t = new Transaction(doc, "Renumber doors"))
{
t.Start();
foreach (var door in doors)
door.get_Parameter(BuiltInParameter.ALL_MODEL_MARK).Set(mark);
t.Commit();
}Without it:
Autodesk.Revit.Exceptions.InvalidOperationException:
Attempting to modify the model outside of transaction.- The transaction name is what appears in Revit's undo list. Name it for the user, not for yourself.
t.RollBack()discards everything sinceStart(). Use it when a partial change would be worse than none.SubTransactionnests inside an open transaction for finer rollback control.TransactionGroupwraps multiple transactions into a single undo entry, withAssimilate()to merge them orRollBack()to discard the lot.[Transaction(TransactionMode.Manual)]on your command class is what you want — you manage transactions.ReadOnlyfor commands that only read.- Failure handling:
t.SetFailureHandlingOptions()lets you suppress or handle the warning dialogs Revit would otherwise pop up mid-transaction. Essential for batch work, because a modal dialog in a loop hangs Revit.
In pyRevit, use the context manager and get the same behaviour for free:
with revit.Transaction("Renumber doors"):
... # rolls back if an exception escapes the blockRule 2: the API is single-threaded
All model access happens on Revit's main thread, in a valid API context. You cannot touch the document from a background thread, a timer, or a modeless window's event handler.
When you need to — a dockable panel, a long-running task, a web response arriving — use
ExternalEvent:
public class MyHandler : IExternalEventHandler
{
public string GetName() => "MyHandler";
public void Execute(UIApplication app)
{
// valid API context — safe to open a transaction here
}
}
// once, at startup:
var handler = new MyHandler();
ExternalEvent ev = ExternalEvent.Create(handler);
// from anywhere, any thread:
ev.Raise(); // Revit calls Execute() when it's safeIgnore this and you get crashes that don't reproduce. It is the single biggest architectural constraint on any non-trivial add-in.
The older Idling event is the historical version of the same idea and still works for
deferred work; ExternalEvent is the modern answer.
Querying: FilteredElementCollector
Every read starts here.
var doors = new FilteredElementCollector(doc)
.OfCategory(BuiltInCategory.OST_Doors)
.WhereElementIsNotElementType()
.ToElements();The instance/type distinction
.WhereElementIsNotElementType() // placed instances
.WhereElementIsElementType() // the type definitionsForgetting this is the most common beginner bug: your door count comes back as 4 because you counted types, not doors.
Quick filters vs. slow filters
Order matters, a lot:
| Kind | Examples | Cost |
|---|---|---|
| Quick | OfCategory, OfClass, OwnedByView, WhereElementIsNotElementType, BoundingBoxIntersectsFilter |
Reads the element record only |
| Slow | ElementParameterFilter, FamilyInstanceFilter, anything evaluating a parameter value |
Expands every element |
Always narrow with a quick filter first:
// good
new FilteredElementCollector(doc)
.OfCategory(BuiltInCategory.OST_Walls) // quick
.WhereElementIsNotElementType() // quick
.WherePasses(fireRatingFilter) // slow, but on a small set
// bad — expands every element in the document
new FilteredElementCollector(doc)
.WherePasses(fireRatingFilter)Scope it to a view
new FilteredElementCollector(doc, view.Id) // only what's visible in that viewMuch faster, and frequently what you actually want — it respects the view's visibility, crop, and filters.
Useful specific collectors
// all instances of a specific family type
new FilteredElementCollector(doc)
.WherePasses(new FamilyInstanceFilter(doc, symbolId));
// elements intersecting a bounding box
new FilteredElementCollector(doc)
.WherePasses(new BoundingBoxIntersectsFilter(outline));
// elements in a given design option
new FilteredElementCollector(doc)
.WherePasses(new ElementDesignOptionFilter(optionId));
// everything on a level
new FilteredElementCollector(doc)
.WherePasses(new ElementLevelFilter(levelId));BuiltInCategory and BuiltInParameter are enormous enums covering everything Revit
ships with. Learning to find names in them is most of the API's surface area — and
RevitLookup (a free add-in) is how you find them: select an element, and it shows you
every property, parameter, and enum value.
Parameters
Three ways to reach one, best first:
// 1. Built-in enum — stable across versions and language packs
Parameter p = element.get_Parameter(BuiltInParameter.ALL_MODEL_MARK);
// 2. Shared parameter GUID — stable for your own data
Parameter p = element.get_Parameter(new Guid("9c2f4b7e-...."));
// 3. By name — convenient, breaks under localization and typos
Parameter p = element.LookupParameter("Mark");Then read with the accessor matching the storage type:
switch (p.StorageType)
{
case StorageType.String: p.AsString(); break;
case StorageType.Double: p.AsDouble(); break; // internal units!
case StorageType.Integer: p.AsInteger(); break; // also Yes/No: 0 or 1
case StorageType.ElementId: p.AsElementId(); break;
}
p.AsValueString(); // formatted per project units — for displayCheck p.IsReadOnly before setting. Type parameters reached from an instance are
read-only; get the type element and set it there.
Units: the bug that ships
AsDouble()returns decimal feet for any length, square feet for any area, and cubic feet for volume — regardless of project units. Always.
// convert out
double mm = UnitUtils.ConvertFromInternalUnits(p.AsDouble(), UnitTypeId.Millimeters);
// convert in
p.Set(UnitUtils.ConvertToInternalUnits(3000, UnitTypeId.Millimeters));UnitTypeId is the modern API (Revit 2021+); older code uses DisplayUnitType and
UnitUtils.ConvertToInternalUnits(value, DisplayUnitType.DUT_MILLIMETERS). If you're
maintaining cross-version code, this is one of the APIs that changed.
Angles are in radians. This catches people too.
ElementId vs. UniqueId
element.Id // ElementId — an integer, unique within THIS document only
element.UniqueId // string GUID-ish — stable across sessions and exportElementIdis whatManage → Select by IDuses and what warnings report. Not stable across files or copy/paste.UniqueIdpersists, survives IFC export, and is what you store in an external database.
In Revit 2024+, ElementId moved from int to long internally
(ElementId.Value rather than ElementId.IntegerValue). This broke a lot of add-ins and
scripts — it's why pyRevit needed an int64 fix for Revit 2026. If you see
IntegerValue in old code, that's a migration point.
Creating elements
Creation is via static Create methods on the element classes, not constructors:
Wall wall = Wall.Create(doc, curve, levelId, structural: false);
Floor floor = Floor.Create(doc, profileLoops, floorTypeId, levelId);
FamilyInstance fi = doc.Create.NewFamilyInstance(point, symbol, level, StructuralType.NonStructural);
ViewSheet sheet = ViewSheet.Create(doc, titleBlockTypeId);
Viewport vp = Viewport.Create(doc, sheet.Id, viewId, location);The family symbol must be activated before you place it, which is a classic gotcha:
if (!symbol.IsActive) { symbol.Activate(); doc.Regenerate(); }doc.Regenerate() forces Revit to recompute — needed when you create something and then
immediately query its geometry in the same transaction.
Geometry, briefly
Options opts = new Options { ComputeReferences = true, DetailLevel = ViewDetailLevel.Fine };
GeometryElement geo = element.get_Geometry(opts);
foreach (GeometryObject obj in geo)
{
if (obj is Solid solid && solid.Volume > 0)
foreach (Face face in solid.Faces) { /* ... */ }
if (obj is GeometryInstance inst) // family instances nest their geometry
foreach (GeometryObject nested in inst.GetInstanceGeometry()) { /* ... */ }
}Two traps: family instance geometry is nested inside a GeometryInstance and you must
recurse into it, and a Solid with zero volume is common and meaningless — filter it out.
LocationCurve and LocationPoint are the cheap way to get a wall's line or a column's
point without touching geometry at all. Prefer them.
Add-in scaffolding
using Autodesk.Revit.Attributes;
using Autodesk.Revit.DB;
using Autodesk.Revit.UI;
[Transaction(TransactionMode.Manual)]
[Regeneration(RegenerationOption.Manual)]
public class CountWalls : IExternalCommand
{
public Result Execute(ExternalCommandData data, ref string message, ElementSet elements)
{
UIDocument uidoc = data.Application.ActiveUIDocument;
Document doc = uidoc.Document;
var walls = new FilteredElementCollector(doc)
.OfClass(typeof(Wall))
.WhereElementIsNotElementType()
.Cast<Wall>()
.ToList();
double feet = walls.Sum(w =>
w.get_Parameter(BuiltInParameter.CURVE_ELEM_LENGTH).AsDouble());
TaskDialog.Show("Walls", $"{walls.Count} walls, {feet:F1} ft total.");
return Result.Succeeded;
}
}<?xml version="1.0" encoding="utf-8"?>
<RevitAddIns>
<AddIn Type="Command">
<Name>Count Walls</Name>
<Assembly>C:\RevitAddins\2027\CountWalls.dll</Assembly>
<AddInId>0f8a4c1e-6b3d-4a2f-9c8e-1d5b7a9e3f21</AddInId>
<FullClassName>CountWalls</FullClassName>
<VendorId>RBC</VendorId>
<VendorDescription>Revit Bootcamp</VendorDescription>
</AddIn>
</RevitAddIns>- Manifest goes in
%AppData%\Autodesk\Revit\Addins\<version>\(per-user) or%ProgramData%\Autodesk\Revit\Addins\<version>\(all users). AddInIdmust be a unique GUID per add-in. Generate a new one; don't copy this.- Use
IExternalApplicationinstead when you need startup hooks or ribbon UI:OnStartup/OnShutdown,application.CreateRibbonTab,CreateRibbonPanel,AddItem(new PushButtonData(...)). - Reference assemblies should be Copy Local = false — Revit loads its own.
The .NET version trap
| Revit | Target framework |
|---|---|
| 2024 and earlier | net48 |
| 2025, 2026 | net8.0-windows |
| 2027 | net10.0-windows |
An add-in built against the wrong target silently fails to load. No error, no dialog — your button just isn't there. Multi-target one project and ship a DLL per version. Details and the conditional-compilation pattern in the version matrix.
The errors you'll hit, in order
| Error | Cause |
|---|---|
Attempting to modify the model outside of transaction |
No open Transaction |
Starting a new transaction is not permitted |
A transaction is already open (or you're inside an IUpdater) |
| Element count is suspiciously small | Missing WhereElementIsNotElementType() |
| Values are wrong by a factor of ~304.8 | Internal units — feet, not millimetres |
The family symbol is not active |
Call symbol.Activate() then doc.Regenerate() |
| Add-in doesn't appear at all | Wrong .NET target, bad manifest path, or a load exception (check the journal) |
| Random crashes that don't reproduce | Touching the document off the main thread — use ExternalEvent |
IntegerValue doesn't exist |
Revit 2024+ — ElementId.Value, it's a long now |
| Modal dialog hangs a batch loop | Set failure handling options on the transaction |
| Geometry loop finds nothing | Family instance geometry is nested in a GeometryInstance |
Learning resources
- RevitLookup — install it first. A database browser for the selected element. You cannot learn this API efficiently without it.
- The Revit API docs shipped with the SDK, plus the online API reference — dry but complete.
- Revit API forum on the Autodesk community — the highest-signal place for specific problems.
- The pyRevit interactive console — the fastest feedback loop that exists for poking at the model.
RevitAPI.chmin the SDK, and theAutodesk.Revit.SDKNuGet package for version-pinned reference assemblies.