Win32 CreateWindowEx and ShowWindow: A Complete Window

A complete Win32 window: CreateWindowEx, ShowWindow, the message loop, WM_DESTROY, DPI awareness and a C# LibraryImport version, built with MinGW-w64.

Revised November 2026. This tutorial first appeared in Korean in 2021. Its examples were fragments of the Visual Studio template's InitInstance function: the first one was missing its closing brace, and the second one failed to compile with MinGW-w64 (cannot convert 'WCHAR*' to 'LPCSTR'). This version replaces them with a complete program — class registration, message loop, clean shutdown and DPI awareness — plus a C# version that uses LibraryImport. Every sample was compiled for Windows with MinGW-w64 (GCC 16.2.0) or the .NET 10 SDK; none was run on this machine, which is a Mac. Where we describe run-time behavior, it comes from the Microsoft Learn documentation, not from a screenshot.

This article is for anyone who wants to put a window on screen with the raw Win32 API and ran into one of the usual problems: the process stays alive after the window closes, the text is blurry on a laptop display, or a pasted sample does not compile. The short answer: CreateWindowEx only creates the window, ShowWindow makes it visible, and everything after that is done by the message loop and the window procedure. Leave out any of the three and you get one of those symptoms.

CreateWindow is a macro, not a function

The 2021 article presented CreateWindow as a function. In winuser.h it is a macro: CreateWindow(...) becomes CreateWindowEx(0, ...) with no extended style, and that in turn becomes CreateWindowExW or CreateWindowExA depending on whether UNICODE is defined. The original prototype also listed the tenth parameter as HANDLE; the real type is HINSTANCE.

HWND CreateWindowExW(
    DWORD     dwExStyle,    // WS_EX_... ; CreateWindow passes 0
    LPCWSTR   lpClassName,  // name (or ATOM) registered with RegisterClassExW
    LPCWSTR   lpWindowName, // caption text
    DWORD     dwStyle,      // WS_OVERLAPPEDWINDOW, WS_CHILD, ...
    int X, int Y, int nWidth, int nHeight,
    HWND      hWndParent,   // parent or owner window
    HMENU     hMenu,        // menu, or the control ID for a child window
    HINSTANCE hInstance,
    LPVOID    lpParam);     // arrives in WM_CREATE as CREATESTRUCT.lpCreateParams

The style table in the old post was essentially correct. These are the styles you will actually use:

Style Meaning
WS_OVERLAPPEDWINDOW A normal top-level window: caption, system menu, sizing border, minimize and maximize buttons
WS_POPUP A window without a caption; cannot be combined with WS_CHILD
WS_CHILD A control inside a parent; hWndParent is required
WS_VISIBLE Visible immediately, so no separate ShowWindow call is needed
WS_CLIPCHILDREN The parent does not paint over its children, which reduces flicker
WS_BORDER A thin, non-sizable border

The second 2021 example used only WS_BORDER. Because WS_OVERLAPPED is 0, that still creates an overlapped window, and Windows always gives overlapped windows a title bar — which is why the old screenshot showed one. What you lose is resizing and the minimize/maximize buttons.

What happened when we compiled the 2021 code

The old samples were cut out of Visual Studio's "Windows Desktop Application" template, so we added the template's globals (WCHAR szWindowClass[100] and friends) and compiled them with x86_64-w64-mingw32-g++.

The first sample stopped at the end of the file because the function had no closing brace:

create2021_first.cpp:23:16: error: expected '}' at end of input
   23 |    return TRUE;
      |                ^

With the brace added, the second sample failed with MinGW's default settings:

create2021.cpp:14:24: error: cannot convert 'WCHAR*' {aka 'wchar_t*'} to 'LPCSTR' {aka 'const char*'}
   14 |    hWnd = CreateWindow(szWindowClass, L"타이틀", WS_BORDER,
      |                        ^~~~~~~~~~~~~
…/winuser.h:2203:65: note: initializing argument 2 of 'HWND__* CreateWindowExA(DWORD, LPCSTR, LPCSTR, …)'

This is the classic ANSI/Unicode mix-up. A Visual Studio project sets the character set to Unicode, defines UNICODE, and the macro picks CreateWindowExW, which accepts the WCHAR array and the L"..." literal. MinGW defines nothing, so the macro picks CreateWindowExA, which wants char strings. Adding -DUNICODE -D_UNICODE made the same file compile without warnings.

You can define UNICODE in the build, but we recommend the other fix: call the W functions directly — CreateWindowExW, RegisterClassExW, DefWindowProcW. The code then means the same thing under any build configuration, and non-ASCII captions never pass through the system code page.

The complete program, message loop included

The 2021 article ended with the sequence "define the class, register it, create the window, show it, run the message loop" without showing it. Here it is in one file:

// window.cpp — Unicode, per-monitor V2 DPI aware (Windows 10 1703+)
#include <windows.h>

static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
    switch (msg) {
    case WM_PAINT: {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hwnd, &ps);
        TextOutW(hdc, 16, 16, L"Hello, Win32", 12);
        EndPaint(hwnd, &ps);
        return 0;
    }
    case WM_DPICHANGED: {                 // moved to a monitor with another scale
        RECT* r = reinterpret_cast<RECT*>(lp);
        SetWindowPos(hwnd, nullptr, r->left, r->top, r->right - r->left,
                     r->bottom - r->top, SWP_NOZORDER | SWP_NOACTIVATE);
        return 0;
    }
    case WM_DESTROY:
        PostQuitMessage(0);               // without this the process never exits
        return 0;
    }
    return DefWindowProcW(hwnd, msg, wp, lp);
}

int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int nCmdShow)
{
    SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

    WNDCLASSEXW wc{};
    wc.cbSize        = sizeof(wc);
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInst;
    wc.hCursor       = LoadCursorW(nullptr, IDC_ARROW);
    wc.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1);
    wc.lpszClassName = L"TLabDemoWindow";
    if (!RegisterClassExW(&wc)) return 1;

    UINT dpi = GetDpiForSystem();         // 96 = 100 %, 144 = 150 %
    HWND hwnd = CreateWindowExW(0, L"TLabDemoWindow", L"Demo",
        WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
        MulDiv(500, dpi, 96), MulDiv(300, dpi, 96),
        nullptr, nullptr, hInst, nullptr);
    if (!hwnd) return 1;

    ShowWindow(hwnd, nCmdShow);
    UpdateWindow(hwnd);

    MSG msg;
    while (GetMessageW(&msg, nullptr, 0, 0) > 0) {   // 0 = WM_QUIT, -1 = error
        TranslateMessage(&msg);
        DispatchMessageW(&msg);
    }
    return static_cast<int>(msg.wParam);
}
$ x86_64-w64-mingw32-g++ -std=c++17 -Wall -Wextra -O2 -municode -mwindows window.cpp -o window.exe -luser32 -lgdi32
$ file window.exe
window.exe: PE32+ executable (GUI) x86-64, for MS Windows

No warnings, even with -Wall -Wextra. A few details are worth spelling out:

  • Drop -municode and the link fails with undefined reference to 'WinMain': MinGW only treats wWinMain as the entry point with that flag, and -DUNICODE alone did not change the result. Without UNICODE at all, IDC_ARROW expands to a char* constant and LoadCursorW(nullptr, IDC_ARROW) fails with cannot convert 'LPSTR' to 'LPCWSTR'. Half-configured Unicode breaks in places you would not look first.
  • Never write while (GetMessage(...)). GetMessage returns -1 on error, -1 is true, and the loop spins forever. The GetMessage page on Microsoft Learn warns about exactly this. Comparing with > 0 leaves the loop on both WM_QUIT (0) and errors (-1).
  • Pass nCmdShow to the first ShowWindow call. That is how a shortcut set to "Run: Minimized" reaches your window. The old table's note that SW_FORCEMINIMIZE needs "Windows 2000 or later" no longer matters.
  • UpdateWindow sends WM_PAINT right away so the first frame is drawn immediately. It is optional, but the template keeps it.
Flow diagram. Top row: RegisterClassExW, then CreateWindowExW which sends WM_CREATE, then ShowWindow and UpdateWindow, then the GetMessageW loop. When GetMessageW returns more than 0, DispatchMessageW calls the window procedure. When the window is closed, WM_CLOSE leads to DestroyWindow and WM_DESTROY, where the code calls PostQuitMessage. That posts WM_QUIT, GetMessageW returns 0 and wWinMain returns msg.wParam.
The life of a minimal Win32 window. CreateWindowExW only creates it; ShowWindow makes it visible; the message loop keeps it alive until WM_DESTROY posts WM_QUIT.

WM_DESTROY and PostQuitMessage

Clicking the close button sends WM_CLOSE. DefWindowProcW answers it by calling DestroyWindow, and while the window is being destroyed it receives WM_DESTROY. Calling PostQuitMessage(0) there puts WM_QUIT in the thread's queue; GetMessageW then returns 0 and the loop ends.

Forget that line and you get the "zombie" process: the window is gone, but the loop is asleep waiting for a WM_QUIT that never comes. The opposite mistake happens in programs with several windows: if a secondary window also calls PostQuitMessage, closing it ends the whole application. Call it from the main window only. If you want to ask "Save changes?" before closing, handle WM_CLOSE and simply do not call DestroyWindow when the user cancels.

DPI awareness: prefer the manifest

A program that does not declare DPI awareness is drawn at 96 DPI and then stretched as a bitmap by Windows, so on a 150 % display its text is soft and blurry. The 2021 article did not mention this, but almost every laptop now ships with a scale above 100 %, so you cannot skip it.

The program above calls SetProcessDpiAwarenessContext (available since Windows 10 version 1703) before it creates any window. Microsoft Learn, however, recommends setting the awareness in the application manifest, because it is fixed before your first line of code runs and there is no call order to get wrong. With MinGW you embed it as a resource:

<!-- app.manifest -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
    </windowsSettings>
  </application>
</assembly>
$ printf '#include <winuser.h>\nCREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "app.manifest"\n' > app.rc
$ x86_64-w64-mingw32-windres app.rc -O coff -o app.res.o
$ x86_64-w64-mingw32-g++ … window.cpp app.res.o -o window_m.exe -luser32 -lgdi32

We checked that the resulting exe has an .rsrc section containing the PerMonitorV2 string. Windows versions older than 10 1607 ignore dpiAwareness and read the dpiAware line instead. Use either the manifest or the API, not both: according to the documentation, the API call fails with ERROR_ACCESS_DENIED once the manifest has set the awareness.

Declaring awareness makes scaling your job. A hard-coded 500 × 300 window would look small at 150 %, so the code converts with MulDiv(500, dpi, 96). When the window moves to a monitor with a different scale, WM_DPICHANGED arrives with a suggested rectangle in lParam, and the code simply applies it. Fonts and icons also need to be recreated for the new DPI — the most tedious part of doing UI in raw Win32.

The same window from C# with LibraryImport

You can do all of this from .NET through P/Invoke. Since .NET 7 the recommended form is LibraryImport, whose marshalling code is generated at compile time by a source generator, instead of DllImport, whose marshalling stubs are built at run time. If you declare, say, FindWindow with DllImport and raise SYSLIB1054 to a warning, the analyzer tells you so:

warning SYSLIB1054: Mark the method 'FindWindow' with 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time

SYSLIB1054 is an "info" diagnostic by default and does not show up in a normal build; we added dotnet_diagnostic.SYSLIB1054.severity = warning to .editorconfig to see it. Here is the core of a net10.0-windows project with <AllowUnsafeBlocks>true</AllowUnsafeBlocks> and <OutputType>WinExe</OutputType> (structs and some declarations are shortened with …):

using System.Runtime.InteropServices;

unsafe
{
    Native.SetProcessDpiAwarenessContext(-4);  // PER_MONITOR_AWARE_V2
    nint hInst = Native.GetModuleHandleW(null);
    fixed (char* cls = "TLabDemoWindowCs")
    {
        var wc = new WNDCLASSEXW
        {
            cbSize = (uint)sizeof(WNDCLASSEXW),
            lpfnWndProc = &WndProc,                 // static [UnmanagedCallersOnly] function
            hInstance = hInst,
            hCursor = Native.LoadCursorW(0, 32512), // IDC_ARROW
            hbrBackground = 5 + 1,                  // COLOR_WINDOW + 1
            lpszClassName = cls,
        };
        if (Native.RegisterClassExW(in wc) == 0) return 1;
    }
    uint dpi = Native.GetDpiForSystem();
    nint hwnd = Native.CreateWindowExW(0, "TLabDemoWindowCs", "Demo (C#)",
        0x00CF0000 /* WS_OVERLAPPEDWINDOW */, unchecked((int)0x80000000), unchecked((int)0x80000000),
        (int)(500 * dpi / 96), (int)(300 * dpi / 96), 0, 0, hInst, 0);
    if (hwnd == 0) return 1;
    Native.ShowWindow(hwnd, 1);                     // SW_SHOWNORMAL
    MSG msg;
    while (Native.GetMessageW(&msg, 0, 0, 0) > 0)
    {
        Native.TranslateMessage(&msg);
        Native.DispatchMessageW(&msg);
    }
    return (int)msg.wParam;
}

[UnmanagedCallersOnly]
static nint WndProc(nint hwnd, uint msg, nint wParam, nint lParam)
{
    if (msg == 0x0002) { Native.PostQuitMessage(0); return 0; }   // WM_DESTROY
    return Native.DefWindowProcW(hwnd, msg, wParam, lParam);
}

static unsafe partial class Native
{
    [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    public static partial nint CreateWindowExW(uint exStyle, string cls, string title, uint style,
        int x, int y, int w, int h, nint parent, nint menu, nint inst, nint param);
    [LibraryImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)]
    public static partial bool ShowWindow(nint hwnd, int cmd);
    [LibraryImport("user32.dll")]
    public static partial int GetMessageW(MSG* msg, nint hwnd, uint min, uint max);
    // … RegisterClassExW, TranslateMessage, DispatchMessageW, DefWindowProcW, etc.
}
$ dotnet build -c Release -r win-x64 --self-contained false -p:EnableWindowsTargeting=true
Build succeeded.
$ file bin/Release/net10.0-windows/win-x64/WinCreate.exe
WinCreate.exe: PE32+ executable (GUI) x86-64, for MS Windows

It built without warnings on .NET SDK 10.0.403 (and, again, was not run). These are the real diagnostics we hit while moving declarations to LibraryImport:

Mistake Diagnostic
The containing class is not partial SYSLIB1050, plus CS0751 and CS8795
A string parameter without StringMarshalling error SYSLIB1051: Marshalling string or char without explicit marshalling information is not supported
A bool return without MarshalAs error SYSLIB1051: Marshalling bool without explicit marshalling information is not supported
No AllowUnsafeBlocks in the project error SYSLIB1062: LibraryImportAttribute requires unsafe code

The window procedure is passed as a function pointer (delegate* unmanaged<...>) to a static [UnmanagedCallersOnly] method rather than as a delegate. A delegate passed to native code must be kept alive so the GC does not collect it while Windows still calls it; a static function pointer has no such problem. Delegates and callbacks in general are covered in C# delegates and callbacks.

Measured: executable size, and what to pick in 2026

We measured the C++ program built with MinGW-w64 GCC 16.2.0 at -O2:

Build Size
window.exe (with debug symbols) 154,440 bytes
window_s.exe (stripped with -s) 17,920 bytes
window_m.exe (stripped + DPI manifest) 18,944 bytes
C# WinCreate.dll + WinCreate.exe app host 8,192 + 162,304 bytes, plus the .NET 10 runtime

The stripped C++ build is under 19 KB and imports only USER32, GDI32, KERNEL32 and the Universal CRT (api-ms-win-crt-*), which ships with Windows 10 and later. For a tiny tool you can copy anywhere without an installer, raw Win32 is still the lightest option.

Decision diagram. Start: a new Windows desktop UI. First question: do you need a tiny native exe with no runtime, or are you extending existing Win32 code? If yes, use Win32 C++ as in this article. If no, second question: do you need the Windows 11 Fluent look? If yes, use WinUI 3 from the Windows App SDK. If no, use WinForms or WPF on .NET 10.
Which UI stack to start a new Windows desktop app with in 2026. Raw Win32 is the right answer only in a narrow set of cases.

For a new desktop app, though, there are few reasons to start from raw Win32. You handle every button, font and DPI change yourself, and accessibility (screen readers) and dark mode are on you as well. For line-of-business tools, WinForms or WPF on .NET 10 is far quicker; if you want the Windows 11 look, WinUI 3 from the Windows App SDK is the fit. All of them still sit on CreateWindowEx and a message loop underneath, so the structure in this article is exactly what you need when you debug a frozen UI thread.

To find the handle of a window that another program owns, continue with FindWindow vs EnumWindows; to send it messages without deadlocking, see SendMessage vs PostMessage.

Tested with MinGW-w64 x86_64-w64-mingw32-g++ (GCC) 16.2.0 and .NET SDK 10.0.403 (EnableWindowsTargeting=true) on macOS (Apple Silicon). Compiled for Windows; not run on this machine.

FAQ

Why does my process keep running after I close the window?

The window procedure does not call PostQuitMessage(0) on WM_DESTROY. The window is gone, but the GetMessage loop only ends when it receives WM_QUIT, so the process stays in Task Manager. Call PostQuitMessage(0) when the main window receives WM_DESTROY.

Why does MinGW reject CreateWindow with an L"..." string?

MinGW does not define UNICODE by default, so the CreateWindow macro expands to CreateWindowExA, which takes char strings. Passing a wchar_t string gives error: cannot convert 'WCHAR*' to 'LPCSTR'. Visual Studio projects define UNICODE, which is why the same code compiled there. Calling the W functions such as CreateWindowExW directly avoids the problem.

My window looks blurry on a high-DPI monitor. How do I fix it?

Declare per-monitor DPI awareness, preferably with dpiAwareness PerMonitorV2 in the application manifest, or call SetProcessDpiAwarenessContext before creating any window. Then scale your sizes by the DPI and handle WM_DPICHANGED. Without that, Windows stretches the 96-DPI bitmap of your window and it looks blurry.

  • #Win32
  • #CreateWindowEx
  • #ShowWindow
  • #message loop
  • #DPI
  • #LibraryImport

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑