Metadata tables and tokens, the assembly manifest, type forwarding, and reading them with System.Reflection.Metadata.
Metadata is a set of tables inside the assembly that describe every type, method, field, property, event, attribute and every external type or member referenced. IL refers to them with 4-byte tokens (the high byte is the table, for example 0x06 for MethodDef). The manifest is the part of the metadata that describes the assembly itself: its identity, the assemblies it references, resources and exported (forwarded) types. You can read both without loading anything into the runtime using System.Reflection.Metadata's PEReader and MetadataReader.
Metadata is the structured, language-neutral description of everything an assembly defines and references: types, members, signatures, attributes, generic parameters, and references to other assemblies and types. The manifest is the part of the metadata describing the assembly itself: its identity, referenced assemblies, resources and exported types. Both live in tables inside the PE file, and you can read them without loading the assembly for execution using System.Reflection.Metadata or MetadataLoadContext.
Metadata is a set of tables plus heaps:
| Table (examples) | Describes |
|---|---|
TypeDef |
Types defined here (name, namespace, flags, base type) |
MethodDef, Field, Property, Event, Param |
Members and parameters |
TypeRef, MemberRef |
Types/members used from other assemblies |
CustomAttribute |
Attributes on any element |
GenericParam, GenericParamConstraint |
Generics |
InterfaceImpl, NestedClass |
Relationships |
Assembly, AssemblyRef, ManifestResource, ExportedType |
Manifest |
Heaps store strings (#Strings), user strings (#US for string literals), GUIDs and blobs (signatures, attribute values). Tokens (e.g. 0x06000012 = MethodDef row 0x12) link IL instructions to metadata.
Metadata is what makes .NET self-describing: compilers reference types from it, the CLR builds runtime types from it, reflection reads it, and tools (IntelliSense, decompilers, analysers) rely on it.
AssemblyRef (name, version, culture, public key token) this assembly depends on.[TypeForwardedTo] redirects (used in reference assemblies and refactorings).[assembly: InternalsVisibleTo], TargetFramework, version attributes, etc. (stored as custom attributes on the assembly).Loading an assembly via reflection (Assembly.LoadFrom) executes nothing by itself but binds it into a load context, may lock the file, resolves dependencies, can't be unloaded easily, and may fail for assemblies targeting other frameworks. For tooling, inspect metadata directly:
System.Reflection.Metadata (low-level, fast, no loading):
using var stream = File.OpenRead(path);
using var pe = new PEReader(stream);
var md = pe.GetMetadataReader();
var def = md.GetAssemblyDefinition();
Console.WriteLine($"{md.GetString(def.Name)} {def.Version}");
foreach (var refHandle in md.AssemblyReferences)
{
var r = md.GetAssemblyReference(refHandle);
Console.WriteLine($" -> {md.GetString(r.Name)} {r.Version}");
}
foreach (var typeHandle in md.TypeDefinitions)
{
var t = md.GetTypeDefinition(typeHandle);
Console.WriteLine($"{md.GetString(t.Namespace)}.{md.GetString(t.Name)}");
}
MetadataLoadContext (System.Reflection.MetadataLoadContext package) — reflection-like API (Type, MethodInfo, GetCustomAttributesData) over assemblies loaded for inspection only, with a resolver you control:
var resolver = new PathAssemblyResolver(Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll").Append(path));
using var mlc = new MetadataLoadContext(resolver);
var asm = mlc.LoadFromAssemblyPath(path);
foreach (var type in asm.GetTypes().Where(t => t.GetCustomAttributesData().Any(a => a.AttributeType.Name == "PluginAttribute")))
Console.WriteLine(type.FullName);
You can't execute code or create instances from MetadataLoadContext types, which is exactly what makes it safe for scanning.
Other tools: AssemblyName.GetAssemblyName(path) (identity only), ILSpy/dnSpy/monodis, ildasm, Roslyn reading references, dotnet-symbol.
AssemblyRef versions an assembly expects.A plugin platform scanned a folder at start-up with Assembly.LoadFrom to find plugin types. A malformed or framework-mismatched DLL crashed start-up, and files stayed locked so plugins couldn't be updated without restarting. We replaced scanning with MetadataLoadContext to read attributes and referenced versions (rejecting incompatible plugins with clear errors), and only then loaded valid plugins into collectible AssemblyLoadContexts. Start-up became robust and plugin updates no longer needed host restarts.
The interviewer checks depth on how .NET is self-describing and whether you can build safe tooling.
Green flags: names tables and tokens, explains manifest as assembly-level metadata, type forwarding, System.Reflection.Metadata or MetadataLoadContext for inspection.
Red flags: "metadata is just attributes"; loads untrusted DLLs with Assembly.LoadFrom to inspect them.
A .NET PE file has a CLI header (IMAGE_COR20_HEADER) pointing to the metadata root, which contains stream headers for #~ (compressed tables), #Strings, #US, #GUID and #Blob. Method bodies (IL) are in the PE's code section, referenced by RVA from MethodDef rows.
A metadata token is 4 bytes: high byte = table (0x02 TypeDef, 0x06 MethodDef, 0x0A MemberRef, 0x70 user string...), low 3 bytes = row index. IL instructions like call 0x0A00001F reference members by token; the JIT resolves tokens via metadata when compiling.
Method and field signatures are encoded in the #Blob heap using compact element-type codes (ELEMENT_TYPE_I4, CLASS, GENERICINST, VAR, MVAR...). System.Reflection.Metadata provides SignatureDecoder to decode them into your own representation.
Debug information (sequence points, local names) is stored in Portable PDB format — itself using metadata tables — either as a separate .pdb or embedded. SourceLink metadata maps files to source control URLs.
System.Reflection.Metadata is designed for speed and low allocation (handles are structs, strings read on demand). Tools like Roslyn and the .NET SDK use it heavily. MetadataLoadContext is built on it, adding a reflection-compatible surface.
System.Reflection.Metadata.MetadataLoadContext to find types with a specific attribute.Assembly.LoadFrom for scanning 100 assemblies.using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
string path = args.Length > 0 ? args[0] : typeof(Program).Assembly.Location;
using FileStream stream = File.OpenRead(path);
using var pe = new PEReader(stream);
MetadataReader md = pe.GetMetadataReader();
// Manifest: identity and references
AssemblyDefinition self = md.GetAssemblyDefinition();
Console.WriteLine($"{md.GetString(self.Name)} {self.Version}");
foreach (AssemblyReferenceHandle h in md.AssemblyReferences)
{
AssemblyReference r = md.GetAssemblyReference(h);
Console.WriteLine($" -> {md.GetString(r.Name)} {r.Version}");
}
// Metadata: types and their method tokens
foreach (TypeDefinitionHandle th in md.TypeDefinitions)
{
TypeDefinition t = md.GetTypeDefinition(th);
Console.WriteLine($"{md.GetString(t.Namespace)}.{md.GetString(t.Name)}");
foreach (MethodDefinitionHandle mh in t.GetMethods())
{
int token = MetadataTokens.GetToken(mh);
Console.WriteLine($" 0x{token:X8} {md.GetString(md.GetMethodDefinition(mh).Name)}");
}
}
partial class Program;
Nothing in the inspected assembly is executed or loaded into the runtime.
What happens: Assembly.LoadFrom for scanning.
Why it is wrong: locks files, triggers dependency resolution, can't unload, may fail.
Do instead: System.Reflection.Metadata or MetadataLoadContext.
What happens: using the terms interchangeably. Why it is wrong: manifest is the assembly-level subset. Do instead: explain both.
MetadataLoadContextWhat happens: Activator.CreateInstance on inspection-only types.
Why it is wrong: not supported.
Do instead: load into an execution context after validation.
What happens: MetadataLoadContext fails to resolve System.Runtime.
Why it is wrong: resolver needs the target framework's assemblies.
Do instead: include runtime/reference assemblies paths.
What happens: using GetCustomAttributes (which constructs attributes).
Why it is wrong: executes code and requires loadable types.
Do instead: GetCustomAttributesData.
What happens: tools can't find types in expected assemblies.
Why it is wrong: types may be forwarded.
Do instead: follow ExportedType entries.
What happens: trusting plugin metadata blindly. Why it is wrong: malicious assemblies can declare anything. Do instead: validate signatures and sandbox behaviour.
What happens: hand-parsing blobs.
Why it is wrong: error-prone.
Do instead: use SignatureDecoder and provided readers.
Metadata literacy pays off when debugging tooling, trimming and versioning problems.
| Problem | Metadata angle |
|---|---|
TypeLoadException after upgrade |
A TypeRef points at an assembly that no longer defines or forwards the type |
| Trimming removed a type | The trimmer only keeps what metadata references or annotations prove is used |
| Moving a type between packages | Use TypeForwardedTo to keep old binaries working |
| Scanning third-party DLLs | Read metadata without executing code |
Knowing that IL is just tokens into tables also explains why obfuscators and IL weavers work.
IL (Common Intermediate Language, CIL) is the CPU-independent, stack-based instruction set that C# compiles to. Instructions push and pop values on an evaluation stack: ldarg.0, ldarg.1, add, ret. Calls use metadata tokens (call, callvirt, newobj), and conversions such as box are explicit. You read it with ILSpy, SharpLab or ildasm-style tools. Reading IL helps you spot hidden allocations (boxing, closures), see how async, foreach or using are lowered, and settle "what does the compiler really do" arguments.
Reflection reads the metadata the compiler wrote into an assembly at run time: typeof(T) or obj.GetType() gives a Type, and from it you get PropertyInfo, MethodInfo, FieldInfo and so on, filtered by BindingFlags. It is flexible but slow on hot paths: lookups search metadata, Invoke takes an object[], boxes value types, checks arguments and wraps exceptions in TargetInvocationException. Make it fast by looking up once and caching, turning a MethodInfo into a typed delegate, using MethodInvoker (.NET 8) when you only have a MethodInfo, or [UnsafeAccessor] (.NET 8) to reach a private member with no reflection at all. Reflection also hides dependencies from the trimmer, so in trimmed or Native AOT apps you must annotate with [DynamicallyAccessedMembers] or move the work to a source generator.
An assembly is the unit of deployment, versioning and loading in .NET: a PE file (.dll or .exe) containing IL, metadata, a manifest and optional resources. Its identity is name, version, culture and public key token. In modern .NET there is no GAC, strong-name signatures are not validated at load, and the runtime accepts an assembly whose version is the same or higher than the one referenced, so binding redirects are not needed. Assembly versions still matter for compatibility and for what NuGet and the SDK place in deps.json.
How to tell it (STAR)
In [our plugin host], discovery loaded every DLL and a broken plugin could crash startup. I switched discovery to System.Reflection.Metadata so we read type names without running code. Startup scan time dropped from [X] to [Y] and file locks disappeared.
Last updated 30 Oct 2026.