The Problem: Orphaned Property Definitions
When you rename or delete a property on a page type or block in code, Optimizely CMS doesn't automatically remove it from the database. The old property definition lingers, taking up space and cluttering the content type. A scheduled job that finds and removes these orphaned properties is a common pattern in Optimizely projects. In CMS 13, several APIs used to implement this pattern have been deprecated or redesigned. This post walks through exactly what changed and what the updated implementation looks like.
Optimizely CMS stores content type and property metadata in its own database tables. When you remove a property from a C# model the database row for that property is not automatically cleaned up. ExistsOnModel is the flag that tells you whether a stored property definition still has a corresponding C# property when it returns 'false', the property is a candidate for deletion.
Is a Scheduled Job the Right Tool?
Before reaching for a scheduled job, it's worth pausing. Deleting property definitions is a destructive, irreversible operation. Any content stored against that property in the database is gone. This is the nuclear option it should not be something that runs on a timer in production without careful thought.
Safer alternatives to consider
Review in the CMS admin UI Optimizely's admin area under Admin → Content Types will show you all property definitions for a given type. You can inspect and delete individual properties manually from there, giving you full visibility and control before anything is committed.
A one-shot migration script or initialisation module If you know exactly which properties need removing as part of a specific deployment, an IInitializableModule that runs once on startup can be a more targeted approach. You can scope it precisely to the properties you intend to remove, add guard conditions, and remove the module once the migration is done. There's no risk of it accidentally running again.
Leave them alone Orphaned properties with no content stored against them are largely harmless. They cost a small amount of database space and add noise to the admin UI, but they won't break anything. If in doubt, do nothing until you have a clear reason to act.
When a scheduled job does make sense
A scheduled job is most appropriate when:
You have a large, long-running project where properties accumulate over time and you want a recurring cleanup mechanism.
Your team is disciplined about reviewing the scheduler history and sign-off before each run.
The job is disabled by default and triggered manually rather than on a schedule.
If you do go with a scheduled job, make sure it is not set to run automatically in production until you have verified what it will delete in a lower environment first.
Lets assume for the rest of this post that you have decided that a scheduled job is the best implementation for you
What the Old Code Looked Like (CMS 12 / earlier)
[ScheduledPlugIn(DisplayName = "Remove Unused Properties", GUID = "096f6282-fabb-4254-9284-d059695c8470")]
public class RemoveUnusedProperties(
IContentTypeRepository<PageType> pageTypeRepository,
IPropertyDefinitionRepository propertyDefinitionRepository,
IContentTypeRepository<BlockType> blockTypeRepository) : ScheduledJobBase
{
public override string Execute()
{
OnStatusChanged($"Starting execution of {this.GetType()}");
DeleteUnUsedProperties();
return "Completed Successfully";
}
private void DeleteUnUsedProperties()
{
foreach (var type in pageTypeRepository.List())
{
OnStatusChanged($"Checking type: {type}");
foreach (var property in type.PropertyDefinitions)
{
if (property != null && !property.ExistsOnModel)
{
OnStatusChanged($"Removing property: {property}");
propertyDefinitionRepository.Delete(property);
}
}
}
foreach (var type in blockTypeRepository.List())
{
OnStatusChanged($"Checking type: {type}");
foreach (var property in type.PropertyDefinitions)
{
if (property != null && !property.ExistsOnModel)
{
OnStatusChanged($"Removing property: {property}");
propertyDefinitionRepository.Delete(property);
}
}
}
}
}There are several things to note here:
[ScheduledPlugIn] the old attribute used to register a job with the scheduler.
IContentTypeRepository<PageType> and IContentTypeRepository<BlockType> two separate typed repositories, one for pages and one for blocks, meaning two separate List() calls and a lot of duplicated iteration logic.
IPropertyDefinitionRepository.Delete(property) a dedicated repository method that deletes a property definition directly.
What Changed in CMS 13
[ScheduledPlugIn] → [ScheduledJob]
The [ScheduledPlugIn] attribute from EPiServer.PlugIn is deprecated. You should now use [ScheduledJob] from EPiServer.Scheduler, which is the correct attribute for registering a scheduled job going forward. The parameters are identical, so it's a straight swap.
// Before
[ScheduledPlugIn(DisplayName = "Remove Unused Properties", GUID = "...")]
// After
[ScheduledJob(DisplayName = "Remove Unused Properties", GUID = "...")]
IContentTypeRepository<T> → IContentTypeRepository
The typed generic repositories IContentTypeRepository<PageType> and IContentTypeRepository<BlockType> have been consolidated into the non-generic IContentTypeRepository, whose List() method returns all content types; pages, blocks, and anything else in a single call. This removes the need for two separate repositories and two separate iteration loops. Instead, you filter by model type assignability
foreach (var type in contentTypeRepository.List())
{
if (type.ModelType == null) continue;
if (!typeof(SitePageData).IsAssignableFrom(type.ModelType) &&
!typeof(SiteBlockData).IsAssignableFrom(type.ModelType)) continue;
// process this type...
}Using IsAssignableFrom against your own base classes is a clean way to limit processing to only the content types your project owns.
IPropertyDefinitionRepository.Delete() → IContentTypeRepository.Save()
The `Delete` method on IPropertyDefinitionRepository is now marked obsolete. The recommended approach is to mutate the content type's property collection and save the whole content type:
// Before
propertyDefinitionRepository.Delete(property);
// After — remove the property from the type and save
var writableType = type.CreateWritableClone();
writableType.PropertyDefinitions.Remove(writableProperty);
contentTypeRepository.Save(writableType);This also means IPropertyDefinitionRepository can be removed as a dependency entirely.
Read-only entities require CreateWritableClone()
Entities returned by contentTypeRepository.List() are read-only. Attempting to mutate them directly throws an InvalidOperationException at runtime
Invalid use of read-only entity, call CreateWritableClone() to create a writable clone.
Before modifying a content type you must call CreateWritableClone() to get a mutable copy, then pass that clone to Save()
Logging
My original implementation only surfaced information through OnStatusChanged, which is visible in the scheduled jobs UI while the job is running but is not persisted anywhere. Once the job completes, there is no record of what was actually deleted. In the updated version, ILogger<T> is injected alongside the other dependencies and each deletion is written as a structured log entry
logger.LogInformation(
"Removing unused property '{PropertyName}' (ID: {PropertyId}) from content type '{ContentTypeName}'.",
writableProperty.Name, writableProperty.ID, writableType.Name);
Using named placeholders rather than string interpolation means the property name, ID, and content type are individually queryable fields in any structured logging for wherever your application logs go. This gives you a permanent, searchable audit trail of every property that was ever removed and when. The return value of Execute() has also been updated to produce a human-readable summary rather than a fixed "Completed Successfully" string. Optimizely writes this return value into the scheduled job history, so editors and developers can see exactly what was removed at a glance without having to dig into log files
Completed successfully. Removed 3 unused properties:
- ArticlePage: OldAuthorName
- TeaserBlock: LegacyImage
- StandardPage: DeprecatedTextBoth of these changes together, structured logging and a descriptive return value mean the job is observable at two levels.
The application log for developers
CMS scheduler history for editors.
Updated Implementation
Putting all of the above together, here is the fully updated scheduled job
using EPiServer.Scheduler;
using Microsoft.Extensions.Logging;
using OptimizelyAlloy.Models.Blocks;
using OptimizelyAlloy.Models.Pages;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace OptimizelyAlloy.Business.ScheduledJobs;
[ScheduledJob(DisplayName = "Remove Unused Properties", GUID = "096f6282-fabb-4254-9284-d059695c8470")]
public class RemoveUnusedProperties(
IContentTypeRepository contentTypeRepository,
ILogger<RemoveUnusedProperties> logger) : ScheduledJobBase
{
public override string Execute()
{
OnStatusChanged($"Starting execution of {this.GetType()}");
var removed = DeleteUnUsedProperties();
if (removed.Count == 0)
return "Completed successfully. No unused properties were found.";
var summary = new StringBuilder();
summary.AppendLine($"Completed successfully. Removed {removed.Count} unused propert{(removed.Count == 1 ? "y" : "ies")}:");
foreach (var (contentType, propertyName) in removed)
summary.AppendLine($" - {contentType}: {propertyName}");
return summary.ToString().TrimEnd();
}
private List<(string ContentType, string PropertyName)> DeleteUnUsedProperties()
{
var removed = new List<(string, string)>();
foreach (var type in contentTypeRepository.List())
{
if (type.ModelType == null) continue;
if (!typeof(SitePageData).IsAssignableFrom(type.ModelType) &&
!typeof(SiteBlockData).IsAssignableFrom(type.ModelType)) continue;
OnStatusChanged($"Checking type: {type}");
var unusedProperties = type.PropertyDefinitions
.Where(p => p != null && !p.ExistsOnModel)
.ToList();
if (unusedProperties.Count == 0) continue;
var writableType = type.CreateWritableClone();
foreach (var property in unusedProperties)
{
var writableProperty = writableType.PropertyDefinitions
.FirstOrDefault(p => p.ID == property.ID);
if (writableProperty != null)
{
OnStatusChanged($"Removing property: {writableProperty}");
logger.LogInformation(
"Removing unused property '{PropertyName}' (ID: {PropertyId}) from content type '{ContentTypeName}'.",
writableProperty.Name, writableProperty.ID, writableType.Name);
writableType.PropertyDefinitions.Remove(writableProperty);
removed.Add((writableType.Name, writableProperty.Name));
}
}
contentTypeRepository.Save(writableType);
}
return removed;
}
}Cleaning up your property definitions keeps your Optimizely Admin UI lean and your database tidy. With the shift to CMS 13, the process is more streamlined than ever just ensure you’re using writable clones and logging every change. Happy coding (and cleaning)!