Skip to content

Migrating Delphi 6 → 12/13

How to move a large client from Delphi 6 to Delphi 12/13: what Unicode breaks, how FireDAC differs from ADO, what changes in 64-bit, how to plan the migration without stopping the business and how to move features to .NET services step by step. In an interview this checks whether you know where a migration silently corrupts data and how to catch it before users do.

Before this topic: Object Pascal, VCL and data access.

Middle+

What broke with the switch to Unicode in Delphi 2009, and how do you fix it?

Section titled “What broke with the switch to Unicode in Delphi 2009, and how do you fix it?”
Answer

What it is

In Delphi 6, string is AnsiString: one byte per character, text in the Windows code page, usually 1251 for Cyrillic. Since Delphi 2009, string is UnicodeString: two bytes per character, UTF-16 encoding.

What changed

Type Delphi 6 Delphi 2009+
string AnsiString UnicodeString
Char AnsiChar, 1 byte WideChar, 2 bytes
PChar PAnsiChar PWideChar

How to use

  • Everyday string work — concatenation, Copy, Pos, Length — stays the same.
  • Code where a string is really bytes breaks: writing to a stream, WinAPI buffers, calls into old DLLs.

Common mistake

Computing the size of a character buffer with SizeOf. It returns bytes, while Length returns characters, and under Unicode these are different numbers.

Takeaway: since Delphi 2009 a character is 2 bytes, and everything that assumed it was one byte has to be checked.

How it works

  • AnsiString now carries its code page: type Cp1251String = type AnsiString(1251). When you assign between AnsiString and string, the compiler inserts a conversion.
  • WinAPI functions without a suffix (GetWindowText) now map to the W versions. They expect PWideChar, and buffer sizes are in characters.

Before — after — what to do

In Delphi 6 What breaks What to do
Stream.Write(S[1], Length(S)) only half the string is written bytes from TEncoding, TStreamWriter
SizeOf(Buf) on a Char array size is twice the buffer Length(Buf)
string as a byte buffer bytes change during conversion TBytes
PChar in a third-party DLL import the DLL gets UTF-16 instead of ANSI PAnsiChar and AnsiString
Ch in ['a'..'z'] warning W1050 CharInSet

Pitfalls

  • Hashes and signatures. An MD5 of a string is now computed over different bytes, so old password hashes no longer match. Pin the hash encoding explicitly to whatever it was before.
  • Pointer math via PChar. The step is now 2 bytes. For byte arithmetic use PByte.
  • Third-party components. The Delphi 6 version either won’t build or builds with the same bugs inside.

Rule of thumb

string inside the program, an explicit encoding at the boundary, TBytes for binary data.

Takeaway: find every place where text turns into bytes and make the encoding explicit there.

Under the hood

  • UnicodeString and AnsiString share a header: code page, element size, reference count, length. UTF8String is AnsiString(65001), and RawByteString accepts any AnsiString without re-encoding.
  • The compiler inserts implicit conversions. You don’t see them in the code, they cost an allocation and a copy, and narrowing to ANSI replaces characters missing from the code page with ?.
  • Length counts UTF-16 elements. A character outside the Basic Multilingual Plane, such as an emoji, takes two Chars — a surrogate pair.

Cost

  • Reviewing every boundary: files, streams, sockets, WinAPI buffers, DLLs, BLOB fields, hashes.
  • Cyrillic and Latin strings take twice the memory; for a desktop client this is usually unnoticeable.

Trade-offs

  • The quick path — declare problem strings as AnsiString. It works, but ANSI spreads further and every VCL call adds a conversion.
  • The right path — Unicode inside and an explicit encoding at the boundary. Slower, but no debt is left behind.
  • Don’t change external formats silently. If a partner expects a file in 1251, write 1251 through TEncoding.GetEncoding(1251).

How to check in production

  1. Enable warnings W1057–W1060 in the project and go through each one.
  2. Run scenarios with text outside code page 1251: CJK characters, emoji.
  3. Compare files and exports written by the old and new versions byte by byte.

Takeaway: a Unicode migration is a hunt for every boundary where text becomes bytes, with an explicit encoding at each one.

Delphi 6: a string as bytes
// string = AnsiString: 1 character = 1 byte
procedure SaveText(Stream: TStream; const S: string);
begin
Stream.WriteBuffer(S[1], Length(S)); // under Unicode writes half the string
end;
function WinDir: string;
var
Buf: array[0..MAX_PATH] of Char;
begin
// under Unicode SizeOf(Buf) is twice the number of characters in the buffer
SetString(Result, Buf, GetWindowsDirectory(Buf, SizeOf(Buf)));
end;
// the old DLL expects ANSI, but under Unicode PChar = PWideChar
function LegacyCheck(Code: PChar): Integer; stdcall; external 'legacy.dll';
Delphi 12: an explicit encoding at the boundary
procedure SaveText(Stream: TStream; const S: string);
var
Bytes: TBytes;
begin
Bytes := TEncoding.UTF8.GetBytes(S); // the encoding is explicit
if Length(Bytes) > 0 then
Stream.WriteBuffer(Bytes[0], Length(Bytes));
end;
function WinDir: string;
var
Buf: array[0..MAX_PATH] of Char;
begin
SetString(Result, Buf, GetWindowsDirectory(Buf, Length(Buf))); // size in characters
end;
function LegacyCheck(Code: PAnsiChar): Integer; stdcall; external 'legacy.dll';
// call: LegacyCheck(PAnsiChar(AnsiString(S)))

AnsiString(S) converts the string to the system code page. If the DLL expects a specific encoding, declare an AnsiString(1251) type. For an old file format, use TEncoding.GetEncoding(1251) instead of TEncoding.UTF8; that instance must be freed.

Middle+

How do you move the data layer from ADO to FireDAC, and what should you check?

Section titled “How do you move the data layer from ADO to FireDAC, and what should you check?”
Answer

What it is

FireDAC is the main database access library in modern Delphi. The ADO components (dbGo) still ship with the product, but new features go into FireDAC.

Component mapping

ADO (dbGo) FireDAC
TADOConnection TFDConnection
TADOQuery, TADODataSet TFDQuery
TADOStoredProc TFDStoredProc
TADOCommand TFDCommand
TADOTable TFDTable

How to use

  1. Replace the components in .pas and .dfm files — by hand or with reFind.
  2. Set up TFDConnection for MS SQL Server.
  3. Open every form and check its fields and parameters.

Common mistake

Assuming everything works once the names are replaced. If a field type changed, the form fails on open with Type mismatch for field.

Takeaway: components map one to one, but behavior has to be checked on every form.

How it works

What ADO FireDAC
Procedure parameters Parameters, Direction Params, ParamType
Parameters from metadata Parameters.Refresh Prepare with fiMeta in FetchOptions.Items
Transactions BeginTrans, CommitTrans StartTransaction, Commit

What to check, step by step

  1. Field types. nvarchar → TWideStringField, nvarchar(max) → TWideMemoField. Persistent fields in the .dfm must match what FireDAC returns; close the gaps with MapRules.
  2. Procedure parameters. Name, type, direction, size. FireDAC doesn’t support default values from the procedure declaration — pass every parameter.
  3. Strings and NULL. StrsTrim (True by default) trims trailing spaces from CHAR/NCHAR; StrsEmpty2Null turns empty strings into NULL.
  4. Date and time. With an old ODBC driver, datetime2, date and time come back as strings — you need a current driver.
  5. Identity columns. IDENTITY is recognized as TFDAutoIncField; check that the new value is visible after Post.
  6. Transactions. By default FireDAC runs in autocommit mode; code with explicit transactions moves to StartTransaction/Commit/Rollback.

Pitfalls

  • ResourceOptions.UnifyParams = True strips @ from parameter names, and ParamByName('@Id') stops finding the parameter.
  • A trigger without SET NOCOUNT ON causes Update command updated [N] instead of [1] record.

Rule of thumb

Close differences with connection settings, not with edits to forms.

Takeaway: names change mechanically, while types, parameters and transactions are checked against a list and by comparison.

Under the hood

  • FireDAC talks to MS SQL Server through Microsoft’s ODBC driver, ADO through an OLE DB provider. Hence different field and parameter types for the same columns.
  • MapRules are applied when a command is prepared, and the first matching rule wins. Set them on TFDConnection and you change the behavior of every dataset at once.
  • Parameters from metadata cost a server round trip at Prepare. Without fiMeta you describe them by hand — which is what you do when the metadata is wrong, e.g. for VARCHAR without a size.

Why automated comparison is needed

  1. Capture a description of every call from both versions: procedure, parameters with type, direction and size.
  2. Normalize the differences: pdInput ↔ ptInput, pdReturnValue ↔ ptResult, @ in names.
  3. Diff them and investigate every mismatch.
  4. Run scenarios on a copy of the production database and compare the results and the data written.

Manually checking a thousand forms is a sample; the comparison reruns after every change.

Cost

  • A comparison tool and a copy of the database for test runs.
  • ODBC Driver 18 encrypts the connection and validates the server certificate by default — infrastructure work before the first run.

Trade-offs

  • Delphi 12 with ADO first, then FireDAC: slower, but each stage is verified on its own.
  • MapRules that keep the old types are faster than recreating persistent fields, but they lock the old types into the new code.

How to check in production

Tracing SQL Server (Extended Events) through the same scenario in the old and new versions shows which calls reached the server and with which parameters.

Takeaway: FireDAC changes data access semantics, so equivalence is proven by automated comparison, not by manual testing.

Delphi 6: ADO
ADOStoredProc1.ProcedureName := 'dbo.GetOrderTotal';
ADOStoredProc1.Parameters.Refresh; // parameters from server metadata
ADOStoredProc1.Parameters.ParamByName('@OrderId').Value := OrderId;
ADOStoredProc1.ExecProc;
Total := ADOStoredProc1.Parameters.ParamByName('@Total').Value;
Delphi 12: FireDAC
// Connection-wide settings: datetime as TDateTimeField, don't trim CHAR spaces
FDConnection1.FormatOptions.OwnMapRules := True;
with FDConnection1.FormatOptions.MapRules.Add do
begin
SourceDataType := dtDateTimeStamp;
TargetDataType := dtDateTime;
end;
FDConnection1.FormatOptions.StrsTrim := False;
FDStoredProc1.StoredProcName := 'dbo.GetOrderTotal';
FDStoredProc1.Prepare; // with fiMeta in FetchOptions.Items, Params are filled automatically
FDStoredProc1.ParamByName('@OrderId').AsInteger := OrderId;
FDStoredProc1.ExecProc;
Total := FDStoredProc1.ParamByName('@Total').AsCurrency;

The dtDateTimeStamp → dtDateTime rule and StrsTrim := False come from the official BDE migration example. Whether you need them is decided by comparing field types and data between the old and new versions.

Middle+

What do you need to watch for when moving to 64-bit?

Section titled “What do you need to watch for when moving to 64-bit?”
Answer

What it is

A 64-bit program can address more memory, and every pointer in it takes 8 bytes instead of 4.

What changes

Type Win32 Win64
Pointer, object references 4 bytes 8 bytes
NativeInt, NativeUInt 4 bytes 8 bytes
Integer, Cardinal, LongInt 4 bytes 4 bytes

How to use

  • A pointer or object stored as an integer — only NativeInt: Tag := NativeInt(Obj).
  • The size of a pointer is SizeOf(Pointer), not the number 4.
  • Add the Windows 64-bit platform in the Project Manager; Win32 and Win64 build into separate EXEs.

Common mistake

Integer(Obj) still compiles on Win64 but cuts off the upper 4 bytes of the address. The bug only shows up when an address lands above 4 GB, so tests often miss it.

Takeaway: on Win64 a pointer is 8 bytes and Integer is still 4; anywhere one was stored in the other has to be fixed.

How it works

  • Pointers, NativeInt, THandle, HWND, WPARAM, LPARAM, LRESULT are 8 bytes; Integer and LongInt on Windows are 4 bytes.
  • On Win64 Extended is an alias for Double: 8 bytes instead of 10. High-precision calculations may differ in the last digit, and binary files containing Extended become incompatible.
  • You can’t mix assembler and Pascal in one function: a function is either entirely asm or entirely Pascal.

Before — after

Before After
Tag := Integer(Obj) Tag := NativeInt(Obj)
SendMessage(..., Integer(@Buf)) LPARAM(@Buf)
SetWindowLong(..., GWL_WNDPROC, ...) SetWindowLongPtr(..., GWLP_WNDPROC, ...)
Message.Result := Integer(Self) LRESULT(Self)
Count * 4 for an array of pointers Count * SizeOf(Pointer)

Pitfalls

  • Dependencies. DLLs, .obj files and in-process COM servers must be 64-bit. An out-of-process COM server (an EXE) works with either bitness.
  • Components. Design-time packages must match the IDE’s bitness, and you need 64-bit versions to build.
  • Database driver. FireDAC needs the 64-bit SQL Server ODBC driver; ADO needs a 64-bit OLE DB provider.

Rule of thumb

Inventory dependencies first, then fix the code.

Takeaway: Win64 code is fixed by pattern, but a dependency without a 64-bit version can block the move entirely.

Under the hood

  • A 32-bit process on 64-bit Windows runs under WOW64 and gets 2 GB of address space, or up to 4 GB with the IMAGE_FILE_LARGE_ADDRESS_AWARE flag. In Delphi the flag is set with the {$SetPEFlags IMAGE_FILE_LARGE_ADDRESS_AWARE} directive.
  • A process has one bitness: a 32-bit DLL can’t be loaded into a 64-bit process, and vice versa.
  • Runtime function patching and IA-32 assembler have to be rewritten for x64 by hand.

When 64-bit isn’t needed

  • The client has enough memory: forms, grids and reports fit into a few hundred megabytes.
  • A key dependency has no 64-bit version: a report generator, a hardware driver, a COM library.

You need 64-bit when the client runs out of memory or when a dependency ships only as 64-bit.

Trade-offs

  • Two platforms double the builds and the testing. If the move is needed, make it a separate stage after Unicode and FireDAC.
  • A 32-bit dependency with no replacement is moved into a separate helper process and called via inter-process communication.

How to check in production

  • A test machine with the AllocationPreference = 0x100000 registry setting: Windows allocates memory top-down, so truncated pointers crash immediately rather than after 4 GB.
  • Comparing Win32 and Win64 calculation results on the same data catches differences caused by Extended.

Takeaway: decide on 64-bit based on dependencies and memory, not fashion; if it brings the client nothing, postpone it.

Delphi 6, Win32: a pointer in an Integer
ListView1.Tag := Integer(Customer); // on Win64 the address gets truncated
SendMessage(Edit1.Handle, WM_SETTEXT, 0, Integer(PChar(Title)));
OldProc := Pointer(SetWindowLong(Handle, GWL_WNDPROC, Integer(@NewWndProc)));
P := Pointer(Integer(P) + 10); // arithmetic through Integer
Move(Src^, Dst^, Count * 4); // 4 is the "pointer size"
Delphi 12: Win32 and Win64
ListView1.Tag := NativeInt(Customer); // Tag is of type NativeInt
SendMessage(Edit1.Handle, WM_SETTEXT, 0, LPARAM(PChar(Title)));
OldProc := Pointer(SetWindowLongPtr(Handle, GWLP_WNDPROC, LONG_PTR(@NewWndProc)));
P := PByte(P) + 10; // PByte supports pointer arithmetic
Move(Src^, Dst^, Count * SizeOf(Pointer));
Senior+

How do you plan the migration of a large Delphi client without stopping the business?

Section titled “How do you plan the migration of a large Delphi client without stopping the business?”
Answer

What it is

Migrating a client means moving the same application to a new Delphi version while keeping its behavior. Users should get the same forms, calculations and reports.

How to use: stages

  1. List the units, forms and third-party components.
  2. Find replacements for components that don’t exist for the new Delphi.
  3. Build the project in the new version and fix the compile errors.
  4. Check forms and reports against the old version.
  5. Give the new version to a small group of users and keep the old one for rollback.

Common mistake

Improving code and UI along the way. Then, when something differs, you can’t tell whether the migration or the improvement caused it.

Takeaway: migrate in stages, compare with the old version and keep the ability to roll back.

Inventory

What What to find out
Units and forms how many, who owns them, how often they’re used
Third-party components is there a version for Delphi 12/13 and source code; what replaces it (RX Library — JVCL or standard VCL)
Data access every query and procedure call with its parameters
External dependencies DLLs, COM, reports, hardware

When to choose what

  • Feature freeze. Simplest, but the business waits. Fits when the migration takes weeks.
  • Two branches. New features go into the old branch and are regularly ported to the new one. You need frequent merges and automated drift checks.
  • One codebase for both compilers. Differences are handled with conditional compilation ({$IFDEF UNICODE}). Branches never drift, but the code fills up with conditions.

Pitfalls

  • The new IDE adds properties like ExplicitWidth to .dfm files on save, so forms are compared after normalization.
  • Binary .dfm files are first converted to text with the convert.exe utility that ships with Delphi.
  • Forms on high-DPI monitors scale differently than in Delphi 6.

Rule of thumb

One stage, one kind of change — and every stage has an automated check and a rollback.

Takeaway: the inventory sets the scope, stages limit the risk, and comparison with the old version proves nothing was lost.

Stages

  1. Inventory and assessment: components, procedures, dependencies, business-critical forms.
  2. Build in Delphi 12/13 with the old ADO: Unicode, component replacement, no behavior changes.
  3. Move from ADO to FireDAC under automated comparison.
  4. If needed — 64-bit, VCL styles, high DPI.

Automated checks

  • Forms. Compare .dfm files: component set, properties, event handlers.
  • Mapping. Persistent fields and their types, DataField and DataSource on controls.
  • Procedure calls. Name and parameters with type, direction and size, then results on a copy of the production database.
  • Behavior. Characterization tests for calculations: inputs from production, the old version’s output as the expected result.

Cost

  • Two branches, or one codebase with conditions, for the whole migration.
  • Comparison tools, a copy of the production database and users’ time for acceptance.

Trade-offs

  • Big bang versus staged. One big release is easier to plan, but a rollback means the whole version. Staged takes longer, but every step is small.
  • Parallel run. Both versions work against one database, the pilot group on the new one. Schema changes must be backward compatible, or rollback becomes impossible.

How to check in production: readiness and rollback

  • The pilot group has worked through a full business cycle, including month-end close.
  • No new errors in the log, and support tickets haven’t gone up.
  • Rollback means starting the old EXE; it works as long as the schema is compatible with both versions.

Takeaway: migrating without stopping the business means small reversible stages, automated comparison with the old version and readiness criteria set in advance.

Parameter snapshot: ADO (Delphi 6)
uses SysUtils, TypInfo, DB, ADODB;
// Describe procedure parameters to compare with the new version's snapshot
function DescribeParams(Proc: TADOStoredProc): string;
var
I: Integer;
P: TParameter;
begin
Result := Proc.ProcedureName;
for I := 0 to Proc.Parameters.Count - 1 do
begin
P := Proc.Parameters[I];
Result := Result + sLineBreak + Format('%s %s %s %d', [P.Name,
GetEnumName(TypeInfo(TParameterDirection), Ord(P.Direction)),
GetEnumName(TypeInfo(TFieldType), Ord(P.DataType)), P.Size]);
end;
end;
Parameter snapshot: FireDAC (Delphi 12)
uses System.SysUtils, System.TypInfo, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param;
function DescribeParams(Proc: TFDStoredProc): string;
var
I: Integer;
P: TFDParam;
begin
Result := Proc.StoredProcName;
for I := 0 to Proc.Params.Count - 1 do
begin
P := Proc.Params[I];
Result := Result + sLineBreak + Format('%s %s %s %d', [P.Name,
GetEnumName(TypeInfo(TParamType), Ord(P.ParamType)),
GetEnumName(TypeInfo(TFieldType), Ord(P.DataType)), P.Size]);
end;
end;

Snapshots from both versions are normalized (pdInput ↔ ptInput, pdReturnValue ↔ ptResult) and compared with a plain diff. Every mismatched line is something to investigate before release.

Senior+

How do you gradually move features from a Delphi client to .NET services?

Section titled “How do you gradually move features from a Delphi client to .NET services?”
Answer

What it is

Gradual migration: the new system grows next to the old one and takes over features one by one. The pattern is covered in Architecture styles.

How to use

  1. Pick a small feature: a lookup, a calculation, a report.
  2. Implement it in a .NET service with an HTTP API.
  3. In the Delphi client, replace the SQL query with a service call.
  4. Compare the results of the old and new paths.
  5. Delete the old code from the client.

Common mistake

Copying a rule into the service and keeping it in the client “just in case”. A month later a fix lands in only one place, and the answers diverge.

Takeaway: move one feature at a time, and every rule must end up with a single owner.

Where to draw the boundary

Where the logic is now What to do
Stored procedures keep them in the database, the service calls them; rewrite later
Form event handlers in the client move into the service — the main win
UI and data entry stay in Delphi

How it works

  • The client calls the service through THTTPClient (System.Net.HttpClient) or the TRESTClient, TRESTRequest, TRESTResponse components from the REST Client Library.
  • A shared database is a transitional state: the service and the client work with the same tables, but only one of them may write to a given table.
  • Read-only features are easier to move: they don’t change data, and results are easy to compare.

Pitfalls

  • Duplicated logic. A rule lives in two places; the old path needs a removal date.
  • A synchronous call on the UI thread. The form freezes while the service responds. You need timeouts and a background call (TTask.Run) that returns to the UI through TThread.Queue.
  • Service unavailable. The client must say so clearly instead of crashing with an exception.

Rule of thumb

One rule, one owner; one table, one writer.

Takeaway: the service takes over features one by one, the client calls it over HTTP, and the shared database lasts only until data ownership moves.

Boundary trade-offs

  • Logic in procedures is already shared by every client. Move it into the service for testability, scaling or a different data source; otherwise the service just calls it.
  • The service gets its own database once nothing but the service writes to those tables. Until then it’s a shared database behind an ACL, so the old schema doesn’t leak into the new model.

Under the hood of a call

  • Authenticating the client’s user in the service is a separate task: a Windows domain account or a token.
  • Old client versions live for a long time, so the API contract changes only compatibly or through a new version.

Cost

  • Network latency and failures where there used to be a local SQL query.
  • Two implementations and their comparison during the transition.

How to check in production

  • Parallel run for calculations. The client computes the old way and also calls the service; mismatches are logged, and the user sees the old result.
  • Service metrics. Latency and errors per endpoint, and the client version in the User-Agent header — so you know when the old path can be removed.
  • Rollback. A flag per branch or user group restores the old path without a new release.

Takeaway: moving to .NET is a strangler fig with an explicit boundary, one owner for each rule and table, and result comparison before the old path is removed.

Before: the client computes it
// The client computes stock with direct SQL against the shared database
function GetStock(const Sku: string): Integer;
begin
StockQuery.SQL.Text := 'SELECT SUM(Qty) FROM StockMoves WHERE Sku = :Sku';
StockQuery.Parameters.ParamByName('Sku').Value := Sku;
StockQuery.Open;
try
Result := StockQuery.Fields[0].AsInteger;
finally
StockQuery.Close;
end;
end;
After: the client calls a .NET service
uses System.SysUtils, System.JSON, System.NetEncoding, System.Net.HttpClient;
function GetStock(const Sku: string): Integer;
var
Client: THTTPClient;
Resp: IHTTPResponse;
Json: TJSONValue;
begin
Client := THTTPClient.Create;
try
Client.ConnectionTimeout := 3000; // ms; the default is one minute
Client.ResponseTimeout := 5000;
Resp := Client.Get('https://stock.internal/api/stock?sku=' + TNetEncoding.URL.Encode(Sku));
if Resp.StatusCode <> 200 then
raise Exception.CreateFmt('Stock service returned HTTP %d', [Resp.StatusCode]);
Json := TJSONObject.ParseJSONValue(Resp.ContentAsString(TEncoding.UTF8));
if Json = nil then
raise Exception.Create('Stock service returned invalid JSON');
try
Result := Json.GetValue<Integer>('quantity');
finally
Json.Free;
end;
finally
Client.Free;
end;
end;

The function signature didn’t change, so the forms aren’t touched. Switching between the old and new implementations fits neatly behind a flag.